Adds a 2-retry / 1s+2s backoff loop to the browser/Electron SuperSync
request path, mirroring the existing native retry. A new typed
NetworkUnavailableSPError replaces the previous string-shape contract
between provider and wrapper: the provider throws the typed error from
both web-retry exhaustion and native-failure handler, and the wrapper
matches via instanceof to show a transient WARNING snackbar
(F.SYNC.S.NETWORK_ERROR) without flipping into a hard ERROR state.
Drops the wrapper-side regex classifier (and its defensive
^HTTP\s+\d{3}\b filter), the now-unused isTransientNetworkError alias,
and dedupes the predicate call inside the web-retry catch. The single
remaining call site for the broad regex (operation-log-upload) now
imports isRetryableUploadError directly under its honest name.
Tests cover the 1s/2s cadence, retry-then-success, retry-exhaustion via
the typed error, and negative paths (AbortError, HTTP-status 5xx,
AuthFailSPError must NOT retry). The error class is added to the
cross-module identity safety net.
39 KiB
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:
- Download new remote ops since
lastServerSeq - Schema-migrate each op (receiver-side)
- Filter ops invalidated by any SYNC_IMPORT (vector clock comparison)
- Detect conflicts via vector clocks against local entity frontier
- No conflicts → apply ops to NgRx store
- Upload pending local ops (encrypted if encryption enabled, batched at 25 per request)
- Process piggybacked ops from upload response
- Handle any rejections
- 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:
- Upload local ops
- Server returns piggybacked ops in response
- Piggybacked ops processed before marking rejected ops (critical ordering for correct conflict detection)
- If no conflicts → apply directly
- 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:
- Download → 0 new ops
- Upload → 0 pending ops
lastServerSequpdated even with no ops (keeps client in sync with server)- 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:
- Download remote ops
- Conflict detection: op's vectorClock is
CONCURRENTwith local entity frontier ConflictResolutionService.autoResolveConflictsLWW():- Compare timestamps → later write wins
- Create merged op with winning data + merged vector clock
- Merged op applied to store, marked as pending
- On next sync, merged op uploaded
- 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:
- Upload op → server rejects with
CONFLICT_CONCURRENT - Piggybacked ops processed first (may contain the winning remote version)
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
- 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:
- Upload op → server rejects with
VALIDATION_ERROR - Op marked as rejected in IndexedDB (won't retry)
permanentRejectionCount > 0→ status set toERROR
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:
- Server returns 413 or error mentioning "Payload too large/complex"
alertDialog()shown (maximum visibility)- Status →
ERROR - 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:
- After
MAX_CONCURRENT_RESOLUTION_ATTEMPTS(configurable) retries for the same entity - Give up: mark op as permanently rejected
- 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:
isWhollyFreshClient()= true_hasMeaningfulStoreData()= false- 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?" - If confirmed → download and apply all remote ops
- 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:
isWhollyFreshClient()= true_hasMeaningfulStoreData()= true (checks for tasks, non-INBOX projects, non-system tags, notes)- Throw
LocalDataConflictError - Show full conflict dialog: USE_LOCAL / USE_REMOTE / CANCEL
- USE_LOCAL →
forceUploadLocalState()(creates SYNC_IMPORT) - 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:
- Download detects remote snapshot (file-based sync path)
- Check unsynced ops for meaningful user data: TASK/PROJECT/TAG/NOTE CREATE/UPDATE ops, or any full-state op (SYNC_IMPORT/BACKUP_IMPORT/REPAIR)
- If meaningful → throw
LocalDataConflictError→ full conflict dialog - 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:
- Download batch contains SYNC_IMPORT/BACKUP_IMPORT/REPAIR
- Check pending local ops → no meaningful pending changes (
_hasMeaningfulPendingOps()= false) - Apply silently via
processRemoteOps()— no dialog. Already-synced store data is not a conflict here; the SYNC_IMPORT is the new authoritative state. - The reason
_hasMeaningfulStoreData()is intentionally NOT checked: prompting an old client whose only "data" is already-synced state would let the user pickUSE_LOCALand force-upload that stale state as a new SYNC_IMPORT, rolling back the remote import for everyone. - 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:
- Download batch contains SYNC_IMPORT
- Check pending local ops → N > 0 (condition satisfied regardless of meaningful data)
- Show conflict dialog BEFORE processing with
scenario: 'INCOMING_IMPORT'andsyncImportReason - USE_LOCAL →
forceUploadLocalState()(overrides remote with local data) - USE_REMOTE →
forceDownloadRemoteState()(clears local ops, downloads from seq 0) - 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:
SyncImportFilterServicefilters incoming remote ops against stored local import- Vector clock comparison:
CONCURRENTorLESS_THAN→ filtered isLocalUnsyncedImport= true (import source is 'local')- Show conflict dialog with
scenario: 'LOCAL_IMPORT_FILTERS_REMOTE'andsyncImportReasonfrom stored import - USE_LOCAL →
forceUploadLocalState() - 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:
SyncImportFilterServicefilters incoming remote ops against stored remote importisLocalUnsyncedImport= false (import source is 'remote')- Silent filter — no dialog
- 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:
- Vector clock comparison returns
CONCURRENT - Special check:
op.clientId === import.clientId && op.vectorClock[op.clientId] > importClock[op.clientId] - 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:
- Upload completes → server returns piggybacked ops containing SYNC_IMPORT
- Check for SYNC_IMPORT in piggybacked ops BEFORE
processRemoteOps() - If found AND
_hasMeaningfulPendingOps()= true (unsynced TASK/PROJECT/TAG/NOTE C/U/D or full-state ops):- Show conflict dialog with
scenario: 'INCOMING_IMPORT'andsyncImportReasonfrom 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
- Show conflict dialog with
- 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:
- Check WebCrypto availability (fail early on Android/insecure context)
runWithSyncBlocked()blocks concurrent syncs- Delete all server data (
deleteAllData()) - Update local config:
isEncryptionEnabled=true, encryptKey=key - Encrypt state snapshot
- Upload encrypted snapshot via snapshot endpoint
- Update
lastServerSeq - Unblock sync
- 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:
- Confirmation dialog required
runWithSyncBlocked()- Delete all server data
- Upload unencrypted snapshot
- Update config:
isEncryptionEnabled=false, encryptKey=undefined - 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:
runWithSyncBlocked()- Check for unsynced ops (error unless
allowUnsyncedOps=true) CleanSlateService.createCleanSlate():- Generate new client ID
- Clear all local op history
- Create fresh SYNC_IMPORT operation
- Update config:
encryptKey = newPassword - Clear derived key cache
- 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:
- Download encrypted ops → decryption fails
- Throw
DecryptErrororDecryptNoPasswordError - Set status →
ERROR - 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:
- Download/upload response:
serverHasOnlyUnencryptedData = true - Local config has
encryptKeyset - Auto-update config:
isEncryptionEnabled=false, encryptKey=undefined - Show snackbar: "Encryption disabled on another device"
- 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:
- After
sync()returnsInSync - Check: provider is SuperSync AND encryption not enabled AND not already showing dialog
- Open
DialogEnableEncryptionComponentininitialSetupmode withdisableClose: true - User MUST set password →
enableEncryption()flow (E.1) - OR user clicks Cancel → sync is disabled entirely (
disableSuperSync()setsisEnabled: false) - 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:
_isEncryptionOperationInProgress= truesync()checks flag → returnHANDLED_ERRORimmediately- Log: "Sync blocked: encryption operation in progress"
- 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:
loadAllDatareducer preservesisEncryptionEnabledas local-only setting (not overwritten by imported config)ImportEncryptionHandlerService: if import would disable encryption → skip- 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:
- Detect during upload via
ServerMigrationService.checkAndHandleMigration() - Double-check server is still empty
- Create SYNC_IMPORT with full current state + merged vector clocks from all local ops
- Upload SYNC_IMPORT as snapshot
- 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:
- Fresh server check finds data (
latestSeq !== 0) - Abort migration (don't create SYNC_IMPORT)
- 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.
Browser/Electron SuperSync requests retry transient fetch failures (for example network changes while switching Wi-Fi) before surfacing the warning.
G.2: CORS Error
Expected: Snackbar with detailed error message (12s duration). Status HANDLED_ERROR.
G.3: Authentication Failure
Expected:
- Clear stale credentials
- Snackbar with "CONFIGURE" action button
- 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:
- Client A:
enableEncryption()→ deletes server, uploads encrypted SYNC_IMPORT - Client B syncs: downloads SYNC_IMPORT
- Client B has pending local ops → conflict dialog shown
- USE_LOCAL: force upload local state (encrypted with the password Client B has)
- USE_REMOTE:
forceDownloadRemoteState()→ resets to seq 0, re-downloads encrypted data → if Client B has no password, fails withDecryptNoPasswordError→ password dialog → user enters password → re-sync - 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:
- Client A:
changePassword()→ clean slate, new SYNC_IMPORT encrypted with new password - Client B syncs: decryption fails (old password)
- Password dialog shown
- User enters new password → sync resumes
- 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:
- Client A: file import → creates local SYNC_IMPORT (unsynced)
- Client A syncs: uploads SYNC_IMPORT
- Client B syncs: downloads SYNC_IMPORT
- Client B has pending ops → conflict dialog
- Client B chooses USE_LOCAL or USE_REMOTE
H.4: Both Clients Import/Force-Upload Simultaneously
Expected flow:
- Client A uploads SYNC_IMPORT first → server accepts
- Client B uploads SYNC_IMPORT → server rejects (or accepts with higher seq)
- 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:
- Each client edits different entities → no conflicts, all merge cleanly
- Each client edits same entity → LWW auto-resolution, last timestamp wins
- 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:
DialogSyncInitialCfgComponentopens_isInitialSetup = true→ hides encryption button/warning in form (handled separately)- User fills in SuperSync access token
save()→ strip_isInitialSetupflag → save config → auth if needed- Check: SuperSync selected AND encryption not enabled → probe server via
downloadOps(0, undefined, 1) - Server is empty (
latestSeq === 0or no ops) → openDialogEnableEncryptionComponentwithinitialSetup: true - User sets password →
enableEncryption():- Check WebCrypto → delete server (empty, no-op) → update config → encrypt snapshot → upload
- OR user clicks Cancel →
disableSuperSync()disables sync entirely (no "skip" option exists) - Dialog closes →
sync()fires (if sync is still enabled) isWhollyFreshClient()= true → nothing to download from empty server- 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:
- Same setup flow as I.1 (config + encryption prompt)
sync()fires → download from server- Server is empty (
latestServerSeq === 0) ANDnewOps.length === 0 - Pre-op-log detection:
isWhollyFreshClient()= true AND_hasMeaningfulStoreData()= true downloadRemoteOps()callsserverMigrationService.handleServerMigration()to create a SYNC_IMPORT from local state- Returns
serverMigrationHandled: true→ upload phase proceeds - SYNC_IMPORT gets uploaded to server → other clients can download it
- 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:
- Client B: setup dialog →
save()→ probe server viadownloadOps(0, undefined, 1) - If server has encrypted data (
isPayloadEncrypted === true):- Open
DialogEnterEncryptionPasswordComponent(enter existing password) - User enters password →
updateEncryptionPassword()setsisEncryptionEnabled = true - No double-prompt — the correct dialog is shown from the start
- Open
- If server has unencrypted data (or probe fails):
- Open
DialogEnableEncryptionComponent(create new password), same as I.1
- Open
sync()fires → download remote ops- Two paths depending on whether server sends snapshot or incremental ops:
- Snapshot path (file-based):
isWhollyFreshClient()= true → showconfirmDialogwith count=1 ("Remote data with 1 changes was found") - Incremental ops path (SuperSync):
isWhollyFreshClient()= true → showconfirmDialogwith actual op count ("Remote data with N changes was found")
- Snapshot path (file-based):
_hasMeaningfulStoreData()= false (brand new client) → simple confirmation, not conflict dialog- If confirmed → apply all remote ops → upload phase (nothing to upload) →
IN_SYNC - 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:
- Client B: setup → server probe → correct encryption prompt (enter or create) →
sync() - Download remote ops →
isWhollyFreshClient()= true (empty op log) _hasMeaningfulStoreData()= true (has tasks/projects/tags)- Throw
LocalDataConflictError→ full conflict dialog: USE_LOCAL / USE_REMOTE / CANCEL - USE_LOCAL →
forceUploadLocalState()→ creates SYNC_IMPORT, overwrites server - USE_REMOTE →
forceDownloadRemoteState()→ clears local, downloads everything - 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:
- Open sync settings → re-enable SuperSync
- Provider-specific config still exists in storage (credentials preserved)
lastServerSeqstill in localStorage (per-account hash key)- Local op log preserved (provider-agnostic)
sync()fires → download ops since storedlastServerSeq- If server data unchanged since disable: quick sync, no new ops
- If other clients pushed ops while disabled: download and merge normally
- Upload any local ops created while offline
- 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:
- Save new config → new
accessTokenand/orbaseUrl lastServerSeqkey changes (hash ofbaseUrl|accessToken), computed dynamically on each sync call- New account starts with
lastServerSeq = 0→ downloads everything from new server - Local op log is preserved (provider-agnostic)
- Important:
syncedAtis a global field, not per-provider/account. Ops previously synced to the old account remain marked as synced and will NOT re-upload individually. - 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:
- Config updated:
syncProvider = SuperSync, credentials saved - Encryption prompt (SuperSync-specific, server probed to determine create vs enter password dialog)
- Op log preserved — all operations stay in IndexedDB
- Vector clocks preserved — causality tracking continues
- Client ID preserved — same device identifier
lastServerSeqfor SuperSync = 0 (never synced to this SuperSync server)- First SuperSync sync:
- Download: empty server → no remote ops
- Server migration:
hasSyncedOps()= true (ops synced to WebDAV havesyncedAtset) → 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)
- 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:
- Config updated:
syncProvider = WebDAV, credentials saved - Op log preserved
- Vector clocks synced to
pf.META_MODEL(bridge for legacy sync —_syncVectorClockToPfapi()) - File-based sync writes full state snapshot to file on first sync
- SuperSync server data remains (not deleted) — user can switch back
- SuperSync
lastServerSeqpreserved 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:
- Config updated:
syncProvider = WebDAV - SuperSync encryption state (
isEncryptionEnabled,encryptKey) stored in SuperSync's privateCfg — not shared with WebDAV - WebDAV has its own
encryptKeyin its privateCfg (initially empty) - User must separately configure encryption for WebDAV if desired (via form field, not dialog)
- 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:
- Config updated:
syncProvider = SuperSync - WebDAV's
encryptKeystays in WebDAV privateCfg - SuperSync starts with
isEncryptionEnabled = false(unless previously configured) - Encryption prompt during setup (server probed — create or enter password depending on server state)
- User sets new password for SuperSync (can be different from WebDAV password)
- 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:
- Each switch preserves op log, vector clocks, client ID
- Each provider has independent
lastServerSeq/ rev tracking - SuperSync's per-account
lastServerSeqkey survives the round-trip (stored in localStorage) - 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
- Resume from stored
- 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:
- Disable: config
isEnabled = false, no sync fires - User creates tasks/projects offline → ops logged to IndexedDB
- Re-enable with new provider (e.g., SuperSync)
- If new server is empty: server migration → SYNC_IMPORT from local state
- 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:
DialogSyncInitialCfgComponent.save()completes config save- Probe server via
downloadOps(0, undefined, 1)to check for existing encrypted data - If server is empty or has unencrypted data:
- Opens
DialogEnableEncryptionComponentwithinitialSetup: true(create new password) - User enters password → component calls
enableEncryption(password) enableEncryption()insiderunWithSyncBlocked():- 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 }
- Opens
- If server has encrypted data (second client joining):
- Opens
DialogEnterEncryptionPasswordComponent(enter existing password) - User enters password →
saveAndSync()callsupdateEncryptionPassword()which setsisEncryptionEnabled = true - Dialog closes
- Opens
save()continues →this._matDialogRef.close()→sync()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:
- Config saved, encryption dialog opens with
initialSetup: trueanddisableClose: true - User's only options are:
- Set password → encryption enabled, dialog closes with
{ success: true } - Cancel → calls
disableSuperSync()which setssync.isEnabled = false, dialog closes with{ success: false }
- Set password → encryption enabled, dialog closes with
- 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)
- 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:
enableEncryption()called →isCryptoSubtleAvailable()returns false- Throw
WebCryptoNotAvailableError→ caught by dialog's try/catch - Dialog shows error snackbar: "Failed to enable encryption: ..."
- Dialog stays open — user can try again or click Cancel (which disables sync entirely)
- 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:
DialogSyncInitialCfgComponentopens,isWasEnabled = true_isInitialSetup = truestill set (always set in this dialog)- Existing provider config loaded: access token, encryption state populated from privateCfg
- If already encrypted:
isEncryptionEnabled = truein model → encryption button hidden (hideExpression) - On save: encryption check — already enabled → skip encryption dialog
- 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
- No silent data loss: Every scenario where user data could be lost MUST show a dialog
- Clean slate semantics: SYNC_IMPORT replaces ALL state; concurrent ops are dropped
- Vector clocks for causality: Never use wall-clock time for conflict decisions
- Encryption is atomic: Server never has mixed encrypted/unencrypted data
- Download before upload: Always get remote state first to detect conflicts early
- Effects use LOCAL_ACTIONS: NgRx effects never fire for remote sync operations
lastServerSeqmonotonically increases: Client never re-downloads same ops- Pending ops survive crashes: IndexedDB is the source of truth for unsynced ops
- Op log is provider-agnostic: Switching providers preserves all operations, vector clocks, and client ID
- Per-account
lastServerSeq: SuperSync tracks sequence numbers perhash(baseUrl|accessToken), not globally - Encryption is per-provider: SuperSync and file-based providers have independent encryption configs in their privateCfg
_isInitialSetupis ephemeral: Set during setup dialog, stripped before config save, never persisted
Known Issues / Open Questions
-
syncedAtis per-operation, not per-provider (I.6, I.7, I.11): Operations have a singlesyncedAttimestamp, 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
isEncryptionEnabledmay still betrue(set bySyncConfigService.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.