Back to Super Productivity

SuperSync Synchronization Scenarios

docs/sync-and-op-log/supersync-scenarios.md

18.4.439.2 KB
Original Source

SuperSync Synchronization Scenarios

Comprehensive spec of all scenarios that can occur during SuperSync synchronization, and the expected behavior for each.


A. Normal Sync

A.1: Standard Incremental Sync (no conflicts) ✓

Trigger: Automatic 1-minute timer or manual sync

Expected:

  1. Download new remote ops since lastServerSeq
  2. Schema-migrate each op (receiver-side)
  3. Filter ops invalidated by any SYNC_IMPORT (vector clock comparison)
  4. Detect conflicts via vector clocks against local entity frontier
  5. No conflicts → apply ops to NgRx store
  6. Upload pending local ops (encrypted if encryption enabled, batched at 25 per request)
  7. Process piggybacked ops from upload response
  8. Handle any rejections
  9. Status → IN_SYNC

User sees: Sync indicator briefly shows syncing, then double-checkmark.

A.2: Sync with Piggybacked Ops ✓

Trigger: Upload response includes ops from other clients

Expected:

  1. Upload local ops
  2. Server returns piggybacked ops in response
  3. Piggybacked ops processed before marking rejected ops (critical ordering for correct conflict detection)
  4. If no conflicts → apply directly
  5. If conflicts → LWW auto-resolution

User sees: Seamless merge, no dialog.

A.3: No Changes on Either Side ✓

Trigger: Sync fires but no new ops anywhere

Expected:

  1. Download → 0 new ops
  2. Upload → 0 pending ops
  3. lastServerSeq updated even with no ops (keeps client in sync with server)
  4. Status → IN_SYNC

User sees: Quick sync, double-checkmark.


B. Conflict Scenarios

B.1: Concurrent Modification — LWW Auto-Resolution ✓

Trigger: Two clients edit the same entity between syncs

Expected:

  1. Download remote ops
  2. Conflict detection: op's vectorClock is CONCURRENT with local entity frontier
  3. ConflictResolutionService.autoResolveConflictsLWW():
    • Compare timestamps → later write wins
    • Create merged op with winning data + merged vector clock
  4. Merged op applied to store, marked as pending
  5. On next sync, merged op uploaded
  6. Other client downloads merged op — no further conflict (merged clock dominates both)

User sees: No dialog. One client's change silently wins based on timestamp.

B.2: Server Rejects Op — CONFLICT_CONCURRENT ✓

Trigger: Server already has a conflicting op for the same entity

Expected:

  1. Upload op → server rejects with CONFLICT_CONCURRENT
  2. Piggybacked ops processed first (may contain the winning remote version)
  3. RejectedOpsHandlerService:
    • Trigger download to get the conflicting op
    • If new ops found: conflict detection resolves it
    • If no new ops: create merged op with current state + merged vector clock
  4. Merged op marked pending, uploaded on next sync

User sees: Sync completes normally (auto-resolved). Possible brief delay.

B.3: Permanent Rejection (VALIDATION_ERROR) ✓

Trigger: Op has invalid data the server won't accept

Expected:

  1. Upload op → server rejects with VALIDATION_ERROR
  2. Op marked as rejected in IndexedDB (won't retry)
  3. permanentRejectionCount > 0 → status set to ERROR

User sees: Error indicator. Op is lost (won't retry).

B.4: Payload Too Large ✓

Trigger: Single op or batch exceeds server size limit

Expected:

  1. Server returns 413 or error mentioning "Payload too large/complex"
  2. alertDialog() shown (maximum visibility)
  3. Status → ERROR
  4. Return HANDLED_ERROR

User sees: Alert dialog explaining the issue. Sync stops.

B.5: Infinite Conflict Loop Prevention ✓

Trigger: Same entity keeps getting rejected due to vector clock pruning artifacts

Expected:

  1. After MAX_CONCURRENT_RESOLUTION_ATTEMPTS (configurable) retries for the same entity
  2. Give up: mark op as permanently rejected
  3. Clear attempt counter

User sees: Snackbar warning. Op permanently rejected.


C. Fresh / New Client Scenarios

C.1: Fresh Client — No Local Data

Trigger: Brand new client (no op history, no meaningful store data) syncs for first time

Expected:

  1. isWhollyFreshClient() = true
  2. _hasMeaningfulStoreData() = false
  3. Show native confirmDialog(): "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?"
  4. If confirmed → download and apply all remote ops
  5. If cancelled → snackbar "Sync cancelled", no data applied

User sees: Simple OK/Cancel confirmation. ✓

C.2: Fresh Client — Has Local Data (pre-op-log era)

Trigger: Client has tasks/projects/tags in NgRx but no operation log history

Expected:

  1. isWhollyFreshClient() = true
  2. _hasMeaningfulStoreData() = true (checks for tasks, non-INBOX projects, non-system tags, notes)
  3. Throw LocalDataConflictError
  4. Show full conflict dialog: USE_LOCAL / USE_REMOTE / CANCEL
  5. USE_LOCAL → forceUploadLocalState() (creates SYNC_IMPORT)
  6. USE_REMOTE → forceDownloadRemoteState() (clears local ops)

User sees: Full conflict resolution dialog.

C.3: Fresh Client — Has Pending Ops with Meaningful User Data (File-Based Sync Only)

Trigger: Client has unsynced ops containing task/project/tag/note create/update actions, receiving a snapshot from a file-based provider

Expected:

  1. Download detects remote snapshot (file-based sync path)
  2. Check unsynced ops for meaningful user data: TASK/PROJECT/TAG/NOTE CREATE/UPDATE ops, or any full-state op (SYNC_IMPORT/BACKUP_IMPORT/REPAIR)
  3. If meaningful → throw LocalDataConflictError → full conflict dialog
  4. If only config/system ops → proceed without dialog

Note: 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. ✓


D. SYNC_IMPORT Scenarios

D.1: Incoming Remote SYNC_IMPORT — No Local Pending Ops ✓

Trigger: Another client uploaded a SYNC_IMPORT (file import, encryption enable, etc.)

Expected:

  1. Download batch contains SYNC_IMPORT/BACKUP_IMPORT/REPAIR
  2. Check pending local ops → no meaningful pending changes (_hasMeaningfulPendingOps() = false)
  3. Apply silently via processRemoteOps() — no dialog. Already-synced store data is not a conflict here; the SYNC_IMPORT is the new authoritative state.
  4. The reason _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.
  5. The dialog does appear only when there are unsynced pending user changes that would actually be discarded — see D.2.

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.

D.2: Incoming Remote SYNC_IMPORT — Has Local Pending Ops ✓

Trigger: Another client uploaded SYNC_IMPORT while this client has unsynced local ops

Expected:

  1. Download batch contains SYNC_IMPORT
  2. Check pending local ops → N > 0 (condition satisfied regardless of meaningful data)
  3. Show conflict dialog BEFORE processing with scenario: 'INCOMING_IMPORT' and syncImportReason
  4. USE_LOCAL → forceUploadLocalState() (overrides remote with local data)
  5. USE_REMOTE → forceDownloadRemoteState() (clears local ops, downloads from seq 0)
  6. CANCEL → return with cancelled: true, skip upload phase

User sees: Conflict dialog explaining remote import detected with local changes at risk. "Use Server Data" recommended.

D.3: Remote Ops Filtered by Stored Local SYNC_IMPORT ✓

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:

  1. SyncImportFilterService filters incoming remote ops against stored local import
  2. Vector clock comparison: CONCURRENT or LESS_THAN → filtered
  3. isLocalUnsyncedImport = true (import source is 'local')
  4. Show conflict dialog with scenario: 'LOCAL_IMPORT_FILTERS_REMOTE' and syncImportReason from stored import
  5. USE_LOCAL → forceUploadLocalState()
  6. USE_REMOTE → forceDownloadRemoteState()

User sees: Conflict dialog. Prevents silent data loss from other clients.

D.4: Remote Ops Filtered by Stored Remote SYNC_IMPORT ✓

Trigger: A previously-downloaded remote SYNC_IMPORT filters subsequent remote ops

Expected:

  1. SyncImportFilterService filters incoming remote ops against stored remote import
  2. isLocalUnsyncedImport = false (import source is 'remote')
  3. Silent filter — no dialog
  4. Log: "N remote ops silently filtered by remote SYNC_IMPORT"

User sees: Nothing. This is correct — the import was already accepted from the remote source. Old concurrent ops are intentionally discarded (clean slate semantics).

D.5: Same-Client Ops After SYNC_IMPORT (Pruning Artifact) ✓

Trigger: Ops from the same client that created the SYNC_IMPORT appear CONCURRENT due to vector clock pruning

Expected:

  1. Vector clock comparison returns CONCURRENT
  2. Special check: op.clientId === import.clientId && op.vectorClock[op.clientId] > importClock[op.clientId]
  3. Keep the op (a client can't create ops concurrent with its own import)

User sees: Nothing. Ops applied normally.

D.6: Piggybacked SYNC_IMPORT — Conflict Dialog ✓

Trigger: Upload response includes a piggybacked SYNC_IMPORT from another client

Expected:

  1. Upload completes → server returns piggybacked ops containing SYNC_IMPORT
  2. Check for SYNC_IMPORT in piggybacked ops BEFORE processRemoteOps()
  3. If found AND _hasMeaningfulPendingOps() = true (unsynced TASK/PROJECT/TAG/NOTE C/U/D or full-state ops):
    • Show conflict dialog with scenario: 'INCOMING_IMPORT' and syncImportReason from the piggybacked op
    • USE_LOCAL → forceUploadLocalState() (overrides remote)
    • USE_REMOTE → forceDownloadRemoteState() (clears local, downloads from seq 0)
    • CANCEL → return with cancelled: true, callers skip post-upload logic
  4. If no meaningful pending ops → processRemoteOps() 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.


E. Encryption Scenarios

E.1: Enable Encryption ✓

Trigger: User clicks "Enable Encryption" in sync settings or initial setup prompt

Expected:

  1. Check WebCrypto availability (fail early on Android/insecure context)
  2. runWithSyncBlocked() blocks concurrent syncs
  3. Delete all server data (deleteAllData())
  4. Update local config: isEncryptionEnabled=true, encryptKey=key
  5. Encrypt state snapshot
  6. Upload encrypted snapshot via snapshot endpoint
  7. Update lastServerSeq
  8. Unblock sync
  9. If upload fails after delete: revert config, show error with recovery instructions

User sees: Encryption dialog → "Encrypting..." → success snackbar. Lock icon appears.

Other clients: Next sync gets DecryptNoPasswordError → password dialog.

E.2: Disable Encryption ✓

Trigger: User clicks "Disable Encryption" in sync settings

Expected:

  1. Confirmation dialog required
  2. runWithSyncBlocked()
  3. Delete all server data
  4. Upload unencrypted snapshot
  5. Update config: isEncryptionEnabled=false, encryptKey=undefined
  6. Clear wrapper cache

User sees: Confirmation → "Disabling..." → success snackbar. Lock icon disappears.

Other clients: Auto-detect unencrypted data → automatically disable local encryption → snackbar warning.

E.3: Change Encryption Password

Trigger: User enters new password in "Enter Encryption Password" dialog with "Use Local Data" option

Expected:

  1. runWithSyncBlocked()
  2. Check for unsynced ops (error unless allowUnsyncedOps=true)
  3. CleanSlateService.createCleanSlate():
    • Generate new client ID
    • Clear all local op history
    • Create fresh SYNC_IMPORT operation
  4. Update config: encryptKey = newPassword
  5. Clear derived key cache
  6. Upload SYNC_IMPORT with isCleanSlate=true (server deletes all existing data)

User sees: Confirmation → "Changing password..." → success.

Other clients: Decryption fails with old password → password dialog.

E.4: Wrong/Missing Encryption Password During Download

Trigger: Server has encrypted data but client has no/wrong password

Expected:

  1. Download encrypted ops → decryption fails
  2. Throw DecryptError or DecryptNoPasswordError
  3. Set status → ERROR
  4. Open DialogEnterEncryptionPasswordComponent:
    • "Save & Sync": save password → retry sync
    • "Use Local Data": changePassword(enteredPassword, {allowUnsyncedOps: true}) → overwrite server with local encrypted data
    • Cancel: close dialog, status stays UNKNOWN_OR_CHANGED

User sees: Error icon → password dialog with two options.

E.5: Encryption State Mismatch (Remote Disabled)

Trigger: Another client disabled encryption; this client still has encryption enabled

Expected:

  1. Download/upload response: serverHasOnlyUnencryptedData = true
  2. Local config has encryptKey set
  3. Auto-update config: isEncryptionEnabled=false, encryptKey=undefined
  4. Show snackbar: "Encryption disabled on another device"
  5. Next sync uses unencrypted mode

User sees: Warning snackbar. Lock icon disappears.

E.6: Encryption Prompt After Every Successful SuperSync Sync (Until Encrypted)

Trigger: SuperSync active without encryption, sync completes successfully

Expected:

  1. After sync() returns InSync
  2. Check: provider is SuperSync AND encryption not enabled AND not already showing dialog
  3. Open DialogEnableEncryptionComponent in initialSetup mode with disableClose: true
  4. User MUST set password → enableEncryption() flow (E.1)
  5. OR user clicks Cancel → sync is disabled entirely (disableSuperSync() sets isEnabled: false)
  6. Dialog closes → if encryption was set, sync() fires again to re-sync with encryption

User 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.

E.7: Encryption Operation Blocks Concurrent Sync

Trigger: Sync fires while password change/enable/disable in progress

Expected:

  1. _isEncryptionOperationInProgress = true
  2. sync() checks flag → return HANDLED_ERROR immediately
  3. Log: "Sync blocked: encryption operation in progress"
  4. After encryption operation completes → flag cleared → next sync proceeds

User sees: Sync silently skipped. Resumes automatically.

E.8: File Import Preserves Encryption State

Trigger: User imports data from file while encryption is enabled

Expected:

  1. loadAllData reducer preserves isEncryptionEnabled as local-only setting (not overwritten by imported config)
  2. ImportEncryptionHandlerService: if import would disable encryption → skip
  3. Encryption stays enabled after import

User sees: Data imported, encryption unchanged.


F. Server Migration

F.1: Client Reconnects to New/Empty Server

Trigger: lastServerSeq === 0 AND server empty AND client has previously synced ops

Expected:

  1. Detect during upload via ServerMigrationService.checkAndHandleMigration()
  2. Double-check server is still empty
  3. Create SYNC_IMPORT with full current state + merged vector clocks from all local ops
  4. Upload SYNC_IMPORT as snapshot
  5. Other clients download SYNC_IMPORT on their next sync

User sees: Upload takes slightly longer (full state). No dialog.

F.2: Migration Aborted — Server No Longer Empty

Trigger: Another client uploaded between the download check and the upload check

Expected:

  1. Fresh server check finds data (latestSeq !== 0)
  2. Abort migration (don't create SYNC_IMPORT)
  3. Continue with normal upload of pending ops

User sees: Normal sync. No migration needed.


G. Error / Edge Cases

G.1: Network Timeout

Expected: Snackbar warning. Ops remain pending. Retry on next sync.

G.2: CORS Error

Expected: Snackbar with detailed error message (12s duration). Status HANDLED_ERROR.

G.3: Authentication Failure

Expected:

  1. Clear stale credentials
  2. Snackbar with "CONFIGURE" action button
  3. User re-enters credentials via dialog

G.4: Transient Server Error (INTERNAL_ERROR)

Expected: Op stays pending (not marked rejected). Silent retry on next sync.

G.5: Duplicate Operation

Expected: Server rejects as duplicate → client marks op as synced. No error shown.

G.6: Storage Quota Exceeded

Expected: Alert dialog (maximum visibility). Ops stay pending. Needs admin intervention.

G.7: Version Mismatch (Schema Too New)

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.

G.8: Operation Migration Failure

Expected: Failed ops skipped. Snackbar shown once per session. Other ops applied normally.

G.9: Concurrent Sync Attempts

Expected: Second attempt returns immediately. "Sync already in progress" logged.

G.10: App Closes During Sync

Expected: Pending ops preserved in IndexedDB. Sync resumes on next app open.


H. Multi-Client Interaction Scenarios

H.1: Client A Enables Encryption, Client B Has Pending Ops

Expected flow:

  1. Client A: enableEncryption() → deletes server, uploads encrypted SYNC_IMPORT
  2. Client B syncs: downloads SYNC_IMPORT
  3. Client B has pending local ops → conflict dialog shown
  4. USE_LOCAL: force upload local state (encrypted with the password Client B has)
  5. USE_REMOTE: forceDownloadRemoteState() → resets to seq 0, re-downloads encrypted data → if Client B has no password, fails with DecryptNoPasswordError → password dialog → user enters password → re-sync
  6. CANCEL: skip sync, status stays UNKNOWN_OR_CHANGED

Previously broken: Client B's ops were silently discarded → deadlock.

H.2: Client A Changes Password, Client B Uses Old Password

Expected flow:

  1. Client A: changePassword() → clean slate, new SYNC_IMPORT encrypted with new password
  2. Client B syncs: decryption fails (old password)
  3. Password dialog shown
  4. User enters new password → sync resumes
  5. If user doesn't know new password → "Use Local Data" option overwrites server

H.3: Client A Imports File, Client B Has Changes

Expected flow:

  1. Client A: file import → creates local SYNC_IMPORT (unsynced)
  2. Client A syncs: uploads SYNC_IMPORT
  3. Client B syncs: downloads SYNC_IMPORT
  4. Client B has pending ops → conflict dialog
  5. Client B chooses USE_LOCAL or USE_REMOTE

H.4: Both Clients Import/Force-Upload Simultaneously

Expected flow:

  1. Client A uploads SYNC_IMPORT first → server accepts
  2. Client B uploads SYNC_IMPORT → server rejects (or accepts with higher seq)
  3. Resolution depends on server behavior:
    • If rejected: Client B downloads A's import, conflict dialog
    • If accepted: last-write-wins at the server level

H.5: Three Clients, Normal Concurrent Edits

Expected flow:

  1. Each client edits different entities → no conflicts, all merge cleanly
  2. Each client edits same entity → LWW auto-resolution, last timestamp wins
  3. Vector clocks ensure causal ordering across all clients

I. Setup & Provider-Switching Scenarios

I.1: First-Time SuperSync Setup — Brand New User (No Existing Data)

Trigger: User opens sync settings for the first time, selects SuperSync, enters access token

Expected:

  1. DialogSyncInitialCfgComponent opens
  2. _isInitialSetup = true → hides encryption button/warning in form (handled separately)
  3. User fills in SuperSync access token
  4. save() → strip _isInitialSetup flag → save config → auth if needed
  5. Check: SuperSync selected AND encryption not enabled → probe server via downloadOps(0, undefined, 1)
  6. Server is empty (latestSeq === 0 or no ops) → open DialogEnableEncryptionComponent with initialSetup: true
  7. User sets password → enableEncryption():
    • Check WebCrypto → delete server (empty, no-op) → update config → encrypt snapshot → upload
  8. OR user clicks Cancel → disableSuperSync() disables sync entirely (no "skip" option exists)
  9. Dialog closes → sync() fires (if sync is still enabled)
  10. isWhollyFreshClient() = true → nothing to download from empty server
  11. Status → IN_SYNC

User sees: Setup dialog → create-password prompt → done. Fresh start.

I.2: First-Time SuperSync Setup — User Has Existing Local Data (Pre-Sync Era) ✓

Trigger: User has been using Super Productivity offline, then sets up SuperSync for the first time

Expected:

  1. Same setup flow as I.1 (config + encryption prompt)
  2. sync() fires → download from server
  3. Server is empty (latestServerSeq === 0) AND newOps.length === 0
  4. Pre-op-log detection: isWhollyFreshClient() = true AND _hasMeaningfulStoreData() = true
  5. downloadRemoteOps() calls serverMigrationService.handleServerMigration() to create a SYNC_IMPORT from local state
  6. Returns serverMigrationHandled: true → upload phase proceeds
  7. SYNC_IMPORT gets uploaded to server → other clients can download it
  8. Status → IN_SYNC

User 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.

I.3: First-Time SuperSync Setup — Server Already Has Data (Second Client)

Trigger: User already uses SuperSync on Client A, now sets up Client B

Expected:

  1. Client B: setup dialog → save()probe server via downloadOps(0, undefined, 1)
  2. If server has encrypted data (isPayloadEncrypted === true):
    • Open DialogEnterEncryptionPasswordComponent (enter existing password)
    • User enters password → updateEncryptionPassword() sets isEncryptionEnabled = true
    • No double-prompt — the correct dialog is shown from the start
  3. If server has unencrypted data (or probe fails):
    • Open DialogEnableEncryptionComponent (create new password), same as I.1
  4. sync() fires → download remote ops
  5. Two paths depending on whether server sends snapshot or incremental ops:
    • Snapshot path (file-based): isWhollyFreshClient() = true → show confirmDialog with count=1 ("Remote data with 1 changes was found")
    • Incremental ops path (SuperSync): isWhollyFreshClient() = true → show confirmDialog with actual op count ("Remote data with N changes was found")
  6. _hasMeaningfulStoreData() = false (brand new client) → simple confirmation, not conflict dialog
  7. If confirmed → apply all remote ops → upload phase (nothing to upload) → IN_SYNC
  8. If cancelled → snackbar "Sync cancelled"

User sees: Setup → correct password prompt (enter or create) → confirmation dialog → data appears.

I.4: First-Time SuperSync Setup — Server Has Data AND Client Has Local Data

Trigger: Client B has offline data, Client A already syncs to SuperSync

Expected:

  1. Client B: setup → server probe → correct encryption prompt (enter or create) → sync()
  2. Download remote ops → isWhollyFreshClient() = true (empty op log)
  3. _hasMeaningfulStoreData() = true (has tasks/projects/tags)
  4. Throw LocalDataConflictError → full conflict dialog: USE_LOCAL / USE_REMOTE / CANCEL
  5. USE_LOCAL → forceUploadLocalState() → creates SYNC_IMPORT, overwrites server
  6. USE_REMOTE → forceDownloadRemoteState() → clears local, downloads everything
  7. CANCEL → sync cancelled, data unchanged

User sees: Full conflict resolution dialog. Critical — prevents silent data loss.

I.5: Re-Enabling SuperSync After Disabling

Trigger: User had SuperSync, disabled sync, then re-enables with same SuperSync account

Expected:

  1. Open sync settings → re-enable SuperSync
  2. Provider-specific config still exists in storage (credentials preserved)
  3. lastServerSeq still in localStorage (per-account hash key)
  4. Local op log preserved (provider-agnostic)
  5. sync() fires → download ops since stored lastServerSeq
  6. If server data unchanged since disable: quick sync, no new ops
  7. If other clients pushed ops while disabled: download and merge normally
  8. Upload any local ops created while offline
  9. Status → IN_SYNC

User 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.

I.6: Switching SuperSync Accounts (Different Token/Server)

Trigger: User changes SuperSync access token or base URL

Expected:

  1. Save new config → new accessToken and/or baseUrl
  2. lastServerSeq key changes (hash of baseUrl|accessToken), computed dynamically on each sync call
  3. New account starts with lastServerSeq = 0 → downloads everything from new server
  4. Local op log is preserved (provider-agnostic)
  5. Important: syncedAt 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.
  6. First sync to new server:
    • Download: gets all ops from new server (if any)
    • If new server empty AND hasSyncedOps() = true: server migration creates SYNC_IMPORT with full current state → complete data transfers to new server
    • If new server has data: download and merge remote ops. Only locally unsynced ops upload (ops synced to old account are skipped). Full data integrity depends on the downloaded remote ops.
    • Client is NOT "fresh" (has snapshot + ops) → no fresh-client checks

User sees: Brief re-sync. Data transfers to new server via SYNC_IMPORT if server is empty.

Key details:

  • Encryption state is per-provider-config. Switching accounts may change encryption state.
  • Switching to an empty server works well (server migration covers full state).
  • Switching to a non-empty server with different data: old account's ops don't re-upload, only SYNC_IMPORT-level transfer or new ops.

Provider-Switching Scenarios

I.7: Switching from File-Based Sync (WebDAV/Dropbox/LocalFile) to SuperSync

Trigger: User currently syncs via WebDAV, switches to SuperSync in settings

Expected:

  1. Config updated: syncProvider = SuperSync, credentials saved
  2. Encryption prompt (SuperSync-specific, server probed to determine create vs enter password dialog)
  3. Op log preserved — all operations stay in IndexedDB
  4. Vector clocks preserved — causality tracking continues
  5. Client ID preserved — same device identifier
  6. lastServerSeq for SuperSync = 0 (never synced to this SuperSync server)
  7. First SuperSync sync:
    • Download: empty server → no remote ops
    • Server migration: hasSyncedOps() = true (ops synced to WebDAV have syncedAt set) → creates SYNC_IMPORT with full current state
    • Uploads SYNC_IMPORT to SuperSync server — this is how complete data transfers, since individual ops won't re-upload (they're globally marked as synced)
  8. File-based sync provider's data remains on old server (WebDAV/Dropbox/local) — not deleted

User sees: Setup → encryption prompt → sync. Data migrates to SuperSync server via SYNC_IMPORT.

Preserved across switch:

  • All tasks, projects, tags, notes (via SYNC_IMPORT full state)
  • Vector clocks
  • Client ID

NOT preserved:

  • Individual op sync status (ops synced to WebDAV stay marked synced, server migration handles data transfer via SYNC_IMPORT instead)
  • lastServerSeq (reset for new provider)
  • File-based sync lock files / rev maps (irrelevant for SuperSync)
  • Encryption key (SuperSync has own encryption config in privateCfg)

I.8: Switching from SuperSync to File-Based Sync (WebDAV/Dropbox/LocalFile)

Trigger: User currently syncs via SuperSync, switches to WebDAV in settings

Expected:

  1. Config updated: syncProvider = WebDAV, credentials saved
  2. Op log preserved
  3. Vector clocks synced to pf.META_MODEL (bridge for legacy sync — _syncVectorClockToPfapi())
  4. File-based sync writes full state snapshot to file on first sync
  5. SuperSync server data remains (not deleted) — user can switch back
  6. SuperSync lastServerSeq preserved in localStorage for future re-switch

User 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.

I.9: Switching from SuperSync (Encrypted) to File-Based Sync

Trigger: User has SuperSync with encryption, switches to WebDAV

Expected:

  1. Config updated: syncProvider = WebDAV
  2. SuperSync encryption state (isEncryptionEnabled, encryptKey) stored in SuperSync's privateCfg — not shared with WebDAV
  3. WebDAV has its own encryptKey in its privateCfg (initially empty)
  4. User must separately configure encryption for WebDAV if desired (via form field, not dialog)
  5. Data uploaded to WebDAV unencrypted unless WebDAV encryption key is set

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.

I.10: Switching from File-Based Sync (Encrypted) to SuperSync

Trigger: User has WebDAV with encryption key set, switches to SuperSync

Expected:

  1. Config updated: syncProvider = SuperSync
  2. WebDAV's encryptKey stays in WebDAV privateCfg
  3. SuperSync starts with isEncryptionEnabled = false (unless previously configured)
  4. Encryption prompt during setup (server probed — create or enter password depending on server state)
  5. User sets new password for SuperSync (can be different from WebDAV password)
  6. Old WebDAV file remains encrypted on WebDAV server

User sees: Switch → sync → encryption prompt → set password.

I.11: Rapid Provider Switching (Back and Forth)

Trigger: User switches SuperSync → WebDAV → SuperSync quickly

Expected:

  1. Each switch preserves op log, vector clocks, client ID
  2. Each provider has independent lastServerSeq / rev tracking
  3. SuperSync's per-account lastServerSeq key survives the round-trip (stored in localStorage)
  4. On return to SuperSync:
    • Resume from stored lastServerSeq
    • Download any ops pushed by other clients while away
    • Only ops created AFTER the WebDAV sync period that weren't synced to WebDAV will upload
  5. Data integrity maintained at the full-state level

User 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:

  • SuperSync already had the data before the switch (it was synced there first)
  • Any new ops created during WebDAV period that weren't yet synced to WebDAV will upload to SuperSync
  • If SuperSync data was lost during the away period, server migration (SYNC_IMPORT) would recreate it from full state

I.12: Disabling Sync Entirely, Then Re-Enabling with Different Provider

Trigger: User disables sync, creates data offline, then enables with a new provider

Expected:

  1. Disable: config isEnabled = false, no sync fires
  2. User creates tasks/projects offline → ops logged to IndexedDB
  3. Re-enable with new provider (e.g., SuperSync)
  4. If new server is empty: server migration → SYNC_IMPORT from local state
  5. If new server has data: depends on isWhollyFreshClient():
    • If op log is non-empty (was syncing before): NOT fresh → normal sync, upload pending ops
    • If op log is empty (never synced): fresh client checks apply (I.3 or I.4)

User sees: Enable sync → data uploads to new provider.


Setup with Encryption — Detailed Flows

I.13: Initial Setup → User Sets Encryption Password

Trigger: First-time SuperSync setup, user enters password in encryption dialog

Expected:

  1. DialogSyncInitialCfgComponent.save() completes config save
  2. Probe server via downloadOps(0, undefined, 1) to check for existing encrypted data
  3. If server is empty or has unencrypted data:
    • Opens DialogEnableEncryptionComponent with initialSetup: true (create new password)
    • User enters password → component calls enableEncryption(password)
    • enableEncryption() inside runWithSyncBlocked():
      • Check WebCrypto available
      • Gather snapshot data
      • Delete server data (empty server → no-op)
      • Update config: isEncryptionEnabled=true, encryptKey=password
      • Encrypt snapshot → upload
    • Dialog closes with { success: true }
  4. If server has encrypted data (second client joining):
    • Opens DialogEnterEncryptionPasswordComponent (enter existing password)
    • User enters password → saveAndSync() calls updateEncryptionPassword() which sets isEncryptionEnabled = true
    • Dialog closes
  5. save() continues → this._matDialogRef.close()sync()
  6. sync() completes → _promptSuperSyncEncryptionIfNeeded():
    • Checks encryption → already enabled → no additional prompt

User sees: Setup → correct password dialog (create or enter) → encrypted sync starts.

I.14: Initial Setup → User Cancels Encryption Dialog

Trigger: First-time SuperSync setup, user doesn't want to set a password

Expected:

  1. Config saved, encryption dialog opens with initialSetup: true and disableClose: true
  2. User's only options are:
    • Set password → encryption enabled, dialog closes with { success: true }
    • Cancel → calls disableSuperSync() which sets sync.isEnabled = false, dialog closes with { success: false }
  3. There is NO "skip" or "continue without encryption" button — the plan described adding one with an "I understand" checkbox, but the current implementation only offers Cancel (which disables sync entirely)
  4. After cancel: save() continues → this._matDialogRef.close()sync() fires but sync is now disabled → fails silently

Current 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).

I.15: Initial Setup → Encryption Fails (WebCrypto Unavailable)

Trigger: Android/insecure context, user tries to set encryption password

Expected:

  1. enableEncryption() called → isCryptoSubtleAvailable() returns false
  2. Throw WebCryptoNotAvailableError → caught by dialog's try/catch
  3. Dialog shows error snackbar: "Failed to enable encryption: ..."
  4. Dialog stays open — user can try again or click Cancel (which disables sync entirely)
  5. No way to proceed with unencrypted SuperSync from this dialog

User 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.

I.16: Re-Opening Settings Dialog for Existing SuperSync Config

Trigger: User already has SuperSync configured, opens sync settings to modify

Expected:

  1. DialogSyncInitialCfgComponent opens, isWasEnabled = true
  2. _isInitialSetup = true still set (always set in this dialog)
  3. Existing provider config loaded: access token, encryption state populated from privateCfg
  4. If already encrypted: isEncryptionEnabled = true in model → encryption button hidden (hideExpression)
  5. On save: encryption check — already enabled → skip encryption dialog
  6. Normal config save → sync

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.


Key Invariants

  1. No silent data loss: Every scenario where user data could be lost MUST show a dialog
  2. Clean slate semantics: SYNC_IMPORT replaces ALL state; concurrent ops are dropped
  3. Vector clocks for causality: Never use wall-clock time for conflict decisions
  4. Encryption is atomic: Server never has mixed encrypted/unencrypted data
  5. Download before upload: Always get remote state first to detect conflicts early
  6. Effects use LOCAL_ACTIONS: NgRx effects never fire for remote sync operations
  7. lastServerSeq monotonically increases: Client never re-downloads same ops
  8. Pending ops survive crashes: IndexedDB is the source of truth for unsynced ops
  9. Op log is provider-agnostic: Switching providers preserves all operations, vector clocks, and client ID
  10. Per-account lastServerSeq: SuperSync tracks sequence numbers per hash(baseUrl|accessToken), not globally
  11. Encryption is per-provider: SuperSync and file-based providers have independent encryption configs in their privateCfg
  12. _isInitialSetup is ephemeral: Set during setup dialog, stripped before config save, never persisted

Known Issues / Open Questions

  1. syncedAt 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.

  2. 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.

  3. 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.