docs/sync-and-op-log/supersync-scenarios.md
Comprehensive spec of all scenarios that can occur during SuperSync synchronization, and the expected behavior for each.
Trigger: Automatic 1-minute timer or manual sync
Expected:
lastServerSeqIN_SYNCUser sees: Sync indicator briefly shows syncing, then double-checkmark.
Trigger: Upload response includes ops from other clients
Expected:
User sees: Seamless merge, no dialog.
Trigger: Sync fires but no new ops anywhere
Expected:
lastServerSeq updated even with no ops (keeps client in sync with server)IN_SYNCUser sees: Quick sync, double-checkmark.
Trigger: Two clients edit the same entity between syncs
Expected:
CONCURRENT with local entity frontierConflictResolutionService.autoResolveConflictsLWW():
User sees: No dialog. One client's change silently wins based on timestamp.
Trigger: Server already has a conflicting op for the same entity
Expected:
CONFLICT_CONCURRENTRejectedOpsHandlerService:
User sees: Sync completes normally (auto-resolved). Possible brief delay.
Trigger: Op has invalid data the server won't accept
Expected:
VALIDATION_ERRORpermanentRejectionCount > 0 → status set to ERRORUser sees: Error indicator. Op is lost (won't retry).
Trigger: Single op or batch exceeds server size limit
Expected:
alertDialog() shown (maximum visibility)ERRORHANDLED_ERRORUser sees: Alert dialog explaining the issue. Sync stops.
Trigger: Same entity keeps getting rejected due to vector clock pruning artifacts
Expected:
MAX_CONCURRENT_RESOLUTION_ATTEMPTS (configurable) retries for the same entityUser sees: Snackbar warning. Op permanently rejected.
Trigger: Brand new client (no op history, no meaningful store data) syncs for first time
Expected:
isWhollyFreshClient() = true_hasMeaningfulStoreData() = falseconfirmDialog(): "Initial Sync — This appears to be a fresh installation. Remote data with X changes was found. Do you want to download and overwrite your local data with it?"User sees: Simple OK/Cancel confirmation. ✓
Trigger: Client has tasks/projects/tags in NgRx but no operation log history
Expected:
isWhollyFreshClient() = true_hasMeaningfulStoreData() = true (checks for tasks, non-INBOX projects, non-system tags, notes)LocalDataConflictErrorforceUploadLocalState() (creates SYNC_IMPORT)forceDownloadRemoteState() (clears local ops)User sees: Full conflict resolution dialog.
Trigger: Client has unsynced ops containing task/project/tag/note create/update actions, receiving a snapshot from a file-based provider
Expected:
LocalDataConflictError → full conflict dialogNote: This op-content check only applies to the file-based snapshot path. For SuperSync (incremental ops path), the fresh client check uses _hasMeaningfulStoreData() (store-based check) instead.
User sees: Conflict dialog only when real user data would be lost. ✓
Trigger: Another client uploaded a SYNC_IMPORT (file import, encryption enable, etc.)
Expected:
_hasMeaningfulPendingOps() = false)processRemoteOps() — no dialog. Already-synced store data is not a conflict here; the SYNC_IMPORT is the new authoritative state._hasMeaningfulStoreData() is intentionally NOT checked: prompting an old client whose only "data" is already-synced state would let the user pick USE_LOCAL and force-upload that stale state as a new SYNC_IMPORT, rolling back the remote import for everyone.User sees: Nothing. Data updates seamlessly to the new authoritative state. The user-facing warning happened on the originating device (D_SERVER_MIGRATION_CONFIRM / encryption flow), not here.
Trigger: Another client uploaded SYNC_IMPORT while this client has unsynced local ops
Expected:
scenario: 'INCOMING_IMPORT' and syncImportReasonforceUploadLocalState() (overrides remote with local data)forceDownloadRemoteState() (clears local ops, downloads from seq 0)cancelled: true, skip upload phaseUser sees: Conflict dialog explaining remote import detected with local changes at risk. "Use Server Data" recommended.
Trigger: This client created a SYNC_IMPORT (e.g., file import, enableEncryption). Later, ops from other clients arrive that are CONCURRENT with the import.
Expected:
SyncImportFilterService filters incoming remote ops against stored local importCONCURRENT or LESS_THAN → filteredisLocalUnsyncedImport = true (import source is 'local')scenario: 'LOCAL_IMPORT_FILTERS_REMOTE' and syncImportReason from stored importforceUploadLocalState()forceDownloadRemoteState()User sees: Conflict dialog. Prevents silent data loss from other clients.
Trigger: A previously-downloaded remote SYNC_IMPORT filters subsequent remote ops
Expected:
SyncImportFilterService filters incoming remote ops against stored remote importisLocalUnsyncedImport = false (import source is 'remote')User sees: Nothing. This is correct — the import was already accepted from the remote source. Old concurrent ops are intentionally discarded (clean slate semantics).
Trigger: Ops from the same client that created the SYNC_IMPORT appear CONCURRENT due to vector clock pruning
Expected:
CONCURRENTop.clientId === import.clientId && op.vectorClock[op.clientId] > importClock[op.clientId]User sees: Nothing. Ops applied normally.
Trigger: Upload response includes a piggybacked SYNC_IMPORT from another client
Expected:
processRemoteOps()_hasMeaningfulPendingOps() = true (unsynced TASK/PROJECT/TAG/NOTE C/U/D or full-state ops):
scenario: 'INCOMING_IMPORT' and syncImportReason from the piggybacked opforceUploadLocalState() (overrides remote)forceDownloadRemoteState() (clears local, downloads from seq 0)cancelled: true, callers skip post-upload logicprocessRemoteOps() applies silently (no dialog) regardless of whether the NgRx store already has user data — that data was already synced and the SYNC_IMPORT is the new authoritative state.Mirrors the download path (D.1 / D.2): the gate is unsynced pending changes, not store contents. Prompting on already-synced store data would let an old client roll back the remote import via USE_LOCAL.
User sees: Nothing when there are no pending changes — the user-facing warning happened on the originating device (D_SERVER_MIGRATION_CONFIRM / encryption flow), see D.1. Conflict dialog only when actual unsynced work is at risk.
Trigger: User clicks "Enable Encryption" in sync settings or initial setup prompt
Expected:
runWithSyncBlocked() blocks concurrent syncsdeleteAllData())isEncryptionEnabled=true, encryptKey=keylastServerSeqUser sees: Encryption dialog → "Encrypting..." → success snackbar. Lock icon appears.
Other clients: Next sync gets DecryptNoPasswordError → password dialog.
Trigger: User clicks "Disable Encryption" in sync settings
Expected:
runWithSyncBlocked()isEncryptionEnabled=false, encryptKey=undefinedUser sees: Confirmation → "Disabling..." → success snackbar. Lock icon disappears.
Other clients: Auto-detect unencrypted data → automatically disable local encryption → snackbar warning.
Trigger: User enters new password in "Enter Encryption Password" dialog with "Use Local Data" option
Expected:
runWithSyncBlocked()allowUnsyncedOps=true)CleanSlateService.createCleanSlate():
encryptKey = newPasswordisCleanSlate=true (server deletes all existing data)User sees: Confirmation → "Changing password..." → success.
Other clients: Decryption fails with old password → password dialog.
Trigger: Server has encrypted data but client has no/wrong password
Expected:
DecryptError or DecryptNoPasswordErrorERRORDialogEnterEncryptionPasswordComponent:
changePassword(enteredPassword, {allowUnsyncedOps: true}) → overwrite server with local encrypted dataUNKNOWN_OR_CHANGEDUser sees: Error icon → password dialog with two options.
Trigger: Another client disabled encryption; this client still has encryption enabled
Expected:
serverHasOnlyUnencryptedData = trueencryptKey setisEncryptionEnabled=false, encryptKey=undefinedUser sees: Warning snackbar. Lock icon disappears.
Trigger: SuperSync active without encryption, sync completes successfully
Expected:
sync() returns InSyncDialogEnableEncryptionComponent in initialSetup mode with disableClose: trueenableEncryption() flow (E.1)disableSuperSync() sets isEnabled: false)sync() fires again to re-sync with encryptionUser sees: Encryption dialog after every sync until encryption is enabled. The only escape is to disable sync. There is no "skip" option — encryption is effectively mandatory for SuperSync.
Trigger: Sync fires while password change/enable/disable in progress
Expected:
_isEncryptionOperationInProgress = truesync() checks flag → return HANDLED_ERROR immediatelyUser sees: Sync silently skipped. Resumes automatically.
Trigger: User imports data from file while encryption is enabled
Expected:
loadAllData reducer preserves isEncryptionEnabled as local-only setting (not overwritten by imported config)ImportEncryptionHandlerService: if import would disable encryption → skipUser sees: Data imported, encryption unchanged.
Trigger: lastServerSeq === 0 AND server empty AND client has previously synced ops
Expected:
ServerMigrationService.checkAndHandleMigration()User sees: Upload takes slightly longer (full state). No dialog.
Trigger: Another client uploaded between the download check and the upload check
Expected:
latestSeq !== 0)User sees: Normal sync. No migration needed.
Expected: Snackbar warning. Ops remain pending. Retry on next sync.
Expected: Snackbar with detailed error message (12s duration). Status HANDLED_ERROR.
Expected:
Expected: Op stays pending (not marked rejected). Silent retry on next sync.
Expected: Server rejects as duplicate → client marks op as synced. No error shown.
Expected: Alert dialog (maximum visibility). Ops stay pending. Needs admin intervention.
Expected: Log warning ("Remote model version newer than local — app update may be required"). Returns HANDLED_ERROR. No alert shown to user. User needs to update app.
Expected: Failed ops skipped. Snackbar shown once per session. Other ops applied normally.
Expected: Second attempt returns immediately. "Sync already in progress" logged.
Expected: Pending ops preserved in IndexedDB. Sync resumes on next app open.
Expected flow:
enableEncryption() → deletes server, uploads encrypted SYNC_IMPORTforceDownloadRemoteState() → resets to seq 0, re-downloads encrypted data → if Client B has no password, fails with DecryptNoPasswordError → password dialog → user enters password → re-syncUNKNOWN_OR_CHANGEDPreviously broken: Client B's ops were silently discarded → deadlock.
Expected flow:
changePassword() → clean slate, new SYNC_IMPORT encrypted with new passwordExpected flow:
Expected flow:
Expected flow:
Trigger: User opens sync settings for the first time, selects SuperSync, enters access token
Expected:
DialogSyncInitialCfgComponent opens_isInitialSetup = true → hides encryption button/warning in form (handled separately)save() → strip _isInitialSetup flag → save config → auth if neededdownloadOps(0, undefined, 1)latestSeq === 0 or no ops) → open DialogEnableEncryptionComponent with initialSetup: trueenableEncryption():
disableSuperSync() disables sync entirely (no "skip" option exists)sync() fires (if sync is still enabled)isWhollyFreshClient() = true → nothing to download from empty serverIN_SYNCUser sees: Setup dialog → create-password prompt → done. Fresh start.
Trigger: User has been using Super Productivity offline, then sets up SuperSync for the first time
Expected:
sync() fires → download from serverlatestServerSeq === 0) AND newOps.length === 0isWhollyFreshClient() = true AND _hasMeaningfulStoreData() = truedownloadRemoteOps() calls serverMigrationService.handleServerMigration() to create a SYNC_IMPORT from local stateserverMigrationHandled: true → upload phase proceedsIN_SYNCUser sees: Upload takes slightly longer (full state SYNC_IMPORT). No dialog.
Safety: handleServerMigration() internally double-checks the server is still empty and skips if local state is empty, so this is safe against races and false positives.
Trigger: User already uses SuperSync on Client A, now sets up Client B
Expected:
save() → probe server via downloadOps(0, undefined, 1)isPayloadEncrypted === true):
DialogEnterEncryptionPasswordComponent (enter existing password)updateEncryptionPassword() sets isEncryptionEnabled = trueDialogEnableEncryptionComponent (create new password), same as I.1sync() fires → download remote opsisWhollyFreshClient() = true → show confirmDialog with count=1 ("Remote data with 1 changes was found")isWhollyFreshClient() = true → show confirmDialog with actual op count ("Remote data with N changes was found")_hasMeaningfulStoreData() = false (brand new client) → simple confirmation, not conflict dialogIN_SYNCUser sees: Setup → correct password prompt (enter or create) → confirmation dialog → data appears.
Trigger: Client B has offline data, Client A already syncs to SuperSync
Expected:
sync()isWhollyFreshClient() = true (empty op log)_hasMeaningfulStoreData() = true (has tasks/projects/tags)LocalDataConflictError → full conflict dialog: USE_LOCAL / USE_REMOTE / CANCELforceUploadLocalState() → creates SYNC_IMPORT, overwrites serverforceDownloadRemoteState() → clears local, downloads everythingUser sees: Full conflict resolution dialog. Critical — prevents silent data loss.
Trigger: User had SuperSync, disabled sync, then re-enables with same SuperSync account
Expected:
lastServerSeq still in localStorage (per-account hash key)sync() fires → download ops since stored lastServerSeqIN_SYNCUser sees: Seamless resume. All local changes sync up.
Edge case: If server was reset/migrated while disabled, lastServerSeq may be ahead of server's actual data. Server returns ops from available seq; client adjusts.
Trigger: User changes SuperSync access token or base URL
Expected:
accessToken and/or baseUrllastServerSeq key changes (hash of baseUrl|accessToken), computed dynamically on each sync calllastServerSeq = 0 → downloads everything from new serversyncedAt is a global field, not per-provider/account. Ops previously synced to the old account remain marked as synced and will NOT re-upload individually.hasSyncedOps() = true: server migration creates SYNC_IMPORT with full current state → complete data transfers to new serverUser sees: Brief re-sync. Data transfers to new server via SYNC_IMPORT if server is empty.
Key details:
Trigger: User currently syncs via WebDAV, switches to SuperSync in settings
Expected:
syncProvider = SuperSync, credentials savedlastServerSeq for SuperSync = 0 (never synced to this SuperSync server)hasSyncedOps() = true (ops synced to WebDAV have syncedAt set) → creates SYNC_IMPORT with full current stateUser sees: Setup → encryption prompt → sync. Data migrates to SuperSync server via SYNC_IMPORT.
Preserved across switch:
NOT preserved:
lastServerSeq (reset for new provider)Trigger: User currently syncs via SuperSync, switches to WebDAV in settings
Expected:
syncProvider = WebDAV, credentials savedpf.META_MODEL (bridge for legacy sync — _syncVectorClockToPfapi())lastServerSeq preserved in localStorage for future re-switchUser sees: Configure WebDAV → sync. Data uploads to WebDAV.
Important: File-based providers use _syncVectorClockToPfapi() before each sync to bridge the vector clock from the op-log store (SUP_OPS) to the legacy persistence layer (pf.META_MODEL). SuperSync doesn't need this bridge.
Trigger: User has SuperSync with encryption, switches to WebDAV
Expected:
syncProvider = WebDAVisEncryptionEnabled, encryptKey) stored in SuperSync's privateCfg — not shared with WebDAVencryptKey in its privateCfg (initially empty)User sees: Switch provider → data syncs without encryption to WebDAV.
Key distinction: SuperSync manages encryption via dedicated dialogs and isEncryptionEnabled flag. File-based providers manage encryption via the form's encryptKey field. They are independent.
Trigger: User has WebDAV with encryption key set, switches to SuperSync
Expected:
syncProvider = SuperSyncencryptKey stays in WebDAV privateCfgisEncryptionEnabled = false (unless previously configured)User sees: Switch → sync → encryption prompt → set password.
Trigger: User switches SuperSync → WebDAV → SuperSync quickly
Expected:
lastServerSeq / rev trackinglastServerSeq key survives the round-trip (stored in localStorage)lastServerSeqUser sees: Seamless transitions. Data intact.
Important nuance: syncedAt is global — ops synced to WebDAV during the away period are marked synced and won't re-upload to SuperSync individually. However, this is typically fine because:
Trigger: User disables sync, creates data offline, then enables with a new provider
Expected:
isEnabled = false, no sync firesisWhollyFreshClient():
User sees: Enable sync → data uploads to new provider.
Trigger: First-time SuperSync setup, user enters password in encryption dialog
Expected:
DialogSyncInitialCfgComponent.save() completes config savedownloadOps(0, undefined, 1) to check for existing encrypted dataDialogEnableEncryptionComponent with initialSetup: true (create new password)enableEncryption(password)enableEncryption() inside runWithSyncBlocked():
isEncryptionEnabled=true, encryptKey=password{ success: true }DialogEnterEncryptionPasswordComponent (enter existing password)saveAndSync() calls updateEncryptionPassword() which sets isEncryptionEnabled = truesave() continues → this._matDialogRef.close() → sync()sync() completes → _promptSuperSyncEncryptionIfNeeded():
User sees: Setup → correct password dialog (create or enter) → encrypted sync starts.
Trigger: First-time SuperSync setup, user doesn't want to set a password
Expected:
initialSetup: true and disableClose: true{ success: true }disableSuperSync() which sets sync.isEnabled = false, dialog closes with { success: false }save() continues → this._matDialogRef.close() → sync() fires but sync is now disabled → fails silentlyCurrent behavior: SuperSync without encryption is effectively impossible. Cancel disables sync entirely. The _promptSuperSyncEncryptionIfNeeded() post-sync hook reinforces this — if somehow SuperSync runs without encryption, it re-opens the same dialog with disableClose: true after every successful sync.
User sees: Encryption dialog. Must set password or cancel (which disables sync).
Trigger: Android/insecure context, user tries to set encryption password
Expected:
enableEncryption() called → isCryptoSubtleAvailable() returns falseWebCryptoNotAvailableError → caught by dialog's try/catchUser sees: Error message. Must either retry or cancel (disabling sync).
Note: This effectively means SuperSync is unusable on platforms without WebCrypto (e.g., Android Capacitor with insecure context). The _promptSuperSyncEncryptionIfNeeded() post-sync hook would also catch this if encryption was somehow bypassed.
Trigger: User already has SuperSync configured, opens sync settings to modify
Expected:
DialogSyncInitialCfgComponent opens, isWasEnabled = true_isInitialSetup = true still set (always set in this dialog)isEncryptionEnabled = true in model → encryption button hidden (hideExpression)User sees: Settings with existing values. No encryption prompt (already set).
Edge case: If user switches from SuperSync to WebDAV and back within the dialog (using provider dropdown), the ngAfterViewInit listener reloads provider-specific config including encryption state.
lastServerSeq monotonically increases: Client never re-downloads same opslastServerSeq: SuperSync tracks sequence numbers per hash(baseUrl|accessToken), not globally_isInitialSetup is ephemeral: Set during setup dialog, stripped before config save, never persistedsyncedAt is per-operation, not per-provider (I.6, I.7, I.11): Operations have a single syncedAt timestamp, not per-provider tracking. When switching providers, ops previously synced to the old provider remain marked synced and won't re-upload individually. This is mitigated by server migration creating a SYNC_IMPORT with full state when connecting to an empty server, but switching to a non-empty server with different data could result in incomplete state.
Encryption state leaking across providers (I.9): When switching from encrypted SuperSync to WebDAV, the global isEncryptionEnabled may still be true (set by SyncConfigService.updateSettingsFromForm() for SuperSync). File-based providers derive encryption from !!encryptKey, so this shouldn't cause issues, but the global config may show misleading state.
No "skip encryption" option for SuperSync (I.14): The encryption dialog's Cancel button disables sync entirely — there's no way to use SuperSync without encryption. This is by design (encryption is effectively mandatory) but may surprise users who want to test without encryption first.