mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
docs(sync): add encryption architecture and scenario documentation
Add comprehensive documentation for the encryption and sync features: - Add SuperSync encryption architecture document - Add SuperSync scenario spec with flowcharts - Add simplified scenario reference - Add SuperSync client simplification plan
This commit is contained in:
parent
baa9a46935
commit
dafee46f7a
5 changed files with 1989 additions and 0 deletions
|
|
@ -235,6 +235,34 @@ privateCfg: {
|
|||
| **Forward Secrecy** | Each operation uses random IV |
|
||||
| **Wrong Password** | Decryption fails, operation rejected |
|
||||
|
||||
## Initial Setup — Password Dialog Selection
|
||||
|
||||
During initial SuperSync setup, the app determines which encryption dialog to show by **probing the server** before opening any dialog:
|
||||
|
||||
```
|
||||
DialogSyncInitialCfgComponent.save()
|
||||
│
|
||||
▼
|
||||
Save config + auth
|
||||
│
|
||||
▼
|
||||
Probe server: downloadOps(0, undefined, 1)
|
||||
│
|
||||
├─── Server has encrypted ops ──► DialogEnterEncryptionPasswordComponent
|
||||
│ (isPayloadEncrypted=true) (enter existing password)
|
||||
│
|
||||
├─── Server empty or ───────────► DialogEnableEncryptionComponent
|
||||
│ unencrypted ops (create new password)
|
||||
│
|
||||
└─── Probe fails ───────────────► DialogEnableEncryptionComponent
|
||||
(network/auth error) (fallback; sync error handling
|
||||
catches mismatches later)
|
||||
```
|
||||
|
||||
This prevents a confusing double-prompt when a second client joins: without the probe, the app would always show "create password", then immediately fail during sync and show "enter password".
|
||||
|
||||
**Safety nets:** If the probe gives wrong results (e.g. race condition), the existing `_handleMissingPasswordDialog()` and `_promptSuperSyncEncryptionIfNeeded()` in `sync-wrapper.service.ts` will catch mismatches during the subsequent sync.
|
||||
|
||||
## Wrong Password Handling
|
||||
|
||||
```
|
||||
|
|
|
|||
94
docs/sync-and-op-log/supersync-scenarios-flowchart.md
Normal file
94
docs/sync-and-op-log/supersync-scenarios-flowchart.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# SuperSync Sync Flow — Mermaid Chart
|
||||
|
||||
Visual overview of the main sync decision tree. For full details see [supersync-scenarios.md](./supersync-scenarios.md).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([Sync Triggered]) --> DL[Download remote ops]
|
||||
DL --> HAS_OPS{Remote ops found?}
|
||||
|
||||
HAS_OPS -->|No| FRESH{Is fresh client?}
|
||||
HAS_OPS -->|Yes| DECRYPT{Encrypted?}
|
||||
|
||||
%% Fresh client path
|
||||
FRESH -->|No| UPLOAD
|
||||
FRESH -->|Yes| LOCAL_DATA{Has local data?}
|
||||
LOCAL_DATA -->|No| CONFIRM[Confirm dialog:<br/>Download remote?]
|
||||
LOCAL_DATA -->|Yes| CONFLICT_DLG[Conflict dialog:<br/>USE_LOCAL / USE_REMOTE / CANCEL]
|
||||
CONFIRM -->|OK| APPLY
|
||||
CONFIRM -->|Cancel| CANCELLED([Sync Cancelled])
|
||||
CONFLICT_DLG -->|Use Local| FORCE_UP[Force upload local state<br/>SYNC_IMPORT]
|
||||
CONFLICT_DLG -->|Use Remote| FORCE_DL[Force download<br/>from seq 0]
|
||||
CONFLICT_DLG -->|Cancel| CANCELLED
|
||||
|
||||
%% Decryption path
|
||||
DECRYPT -->|Yes| DECRYPT_OK{Decryption succeeds?}
|
||||
DECRYPT -->|No| PROCESS
|
||||
DECRYPT_OK -->|Yes| PROCESS
|
||||
DECRYPT_OK -->|No| PWD_DLG[Password dialog:<br/>Save & Sync / Use Local Data]
|
||||
PWD_DLG -->|Save & Sync| START
|
||||
PWD_DLG -->|Use Local| FORCE_UP
|
||||
|
||||
%% Processing remote ops
|
||||
PROCESS[Process remote ops] --> IS_IMPORT{Contains SYNC_IMPORT?}
|
||||
IS_IMPORT -->|Yes| PENDING{Has local pending ops?}
|
||||
IS_IMPORT -->|No| CONFLICT_CHK
|
||||
|
||||
PENDING -->|No| MEANINGFUL{Has meaningful<br/>local data?}
|
||||
MEANINGFUL -->|Yes| IMPORT_DLG[Conflict dialog:<br/>import reason shown,<br/>Use Server Data recommended]
|
||||
MEANINGFUL -->|No| APPLY_IMPORT[Apply full state replacement]
|
||||
IMPORT_DLG -->|Use Server| FORCE_DL
|
||||
IMPORT_DLG -->|Use Local| FORCE_UP
|
||||
IMPORT_DLG -->|Cancel| CANCELLED
|
||||
PENDING -->|Yes| CONFLICT_DLG
|
||||
|
||||
%% Conflict detection
|
||||
CONFLICT_CHK{Vector clock conflict?} -->|CONCURRENT| LWW[Auto-resolve LWW<br/>later timestamp wins]
|
||||
CONFLICT_CHK -->|No conflict| APPLY
|
||||
|
||||
LWW --> APPLY[Apply ops to NgRx store]
|
||||
APPLY_IMPORT --> UPLOAD
|
||||
|
||||
%% Upload phase
|
||||
APPLY --> UPLOAD[Upload pending local ops]
|
||||
UPLOAD --> REJECTED{Server rejects any?}
|
||||
|
||||
REJECTED -->|No| PIGGYBACK[Process piggybacked ops]
|
||||
REJECTED -->|CONFLICT_CONCURRENT| REDOWNLOAD[Re-download & resolve]
|
||||
REJECTED -->|VALIDATION_ERROR| PERM_REJECT[Op permanently rejected]
|
||||
REJECTED -->|Payload too large| ALERT[Alert dialog, sync stops]
|
||||
|
||||
REDOWNLOAD --> CONFLICT_CHK
|
||||
PIGGYBACK --> ENCRYPT_CHK
|
||||
|
||||
%% Post-sync encryption check
|
||||
ENCRYPT_CHK{SuperSync without<br/>encryption?}
|
||||
ENCRYPT_CHK -->|Yes| ENC_PROMPT[Encryption prompt:<br/>Set password or disable sync]
|
||||
ENCRYPT_CHK -->|No| IN_SYNC([IN_SYNC ✓])
|
||||
ENC_PROMPT -->|Password set| ENABLE_ENC[Enable encryption:<br/>delete server → upload encrypted]
|
||||
ENC_PROMPT -->|Cancel| DISABLE([Sync Disabled])
|
||||
ENABLE_ENC --> IN_SYNC
|
||||
|
||||
FORCE_UP --> IN_SYNC
|
||||
FORCE_DL --> IN_SYNC
|
||||
PERM_REJECT --> ERROR([ERROR])
|
||||
ALERT --> ERROR
|
||||
|
||||
%% Styling
|
||||
classDef success fill:#2d6,stroke:#1a4,color:#fff
|
||||
classDef error fill:#d33,stroke:#a11,color:#fff
|
||||
classDef cancel fill:#888,stroke:#555,color:#fff
|
||||
classDef dialog fill:#48f,stroke:#26d,color:#fff
|
||||
|
||||
class IN_SYNC success
|
||||
class ERROR error
|
||||
class CANCELLED,DISABLE cancel
|
||||
class CONFIRM,CONFLICT_DLG,IMPORT_DLG,PWD_DLG,ENC_PROMPT dialog
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
|
||||
- 🟢 Green = success states
|
||||
- 🔴 Red = error states
|
||||
- 🔵 Blue = user-facing dialogs
|
||||
- ⚫ Gray = cancelled/disabled
|
||||
94
docs/sync-and-op-log/supersync-scenarios-simplified.md
Normal file
94
docs/sync-and-op-log/supersync-scenarios-simplified.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# SuperSync Scenarios — Simplified
|
||||
|
||||
Condensed reference for all SuperSync synchronization scenarios. For full details see [supersync-scenarios.md](./supersync-scenarios.md).
|
||||
|
||||
---
|
||||
|
||||
## A. Normal Sync
|
||||
|
||||
- **Incremental sync**: Download remote ops → detect conflicts → apply → upload local ops → done
|
||||
- **Piggybacked ops**: Upload response includes other clients' ops, processed inline
|
||||
- **No changes**: Quick round-trip, seq updated, status IN_SYNC
|
||||
|
||||
## B. Conflicts
|
||||
|
||||
- **Concurrent edits**: Auto-resolved via Last-Writer-Wins (timestamp comparison). No dialog.
|
||||
- **Server rejects (CONFLICT_CONCURRENT)**: Re-download, auto-resolve, retry next sync
|
||||
- **Validation error**: Op permanently rejected, status ERROR
|
||||
- **Payload too large**: Alert dialog, sync stops
|
||||
- **Infinite loop**: After max retries, op permanently rejected
|
||||
|
||||
## C. Fresh Client
|
||||
|
||||
- **No local data**: Confirm dialog → download all remote ops
|
||||
- **Has local data (pre-op-log)**: Full conflict dialog (USE_LOCAL / USE_REMOTE / CANCEL)
|
||||
- **Has meaningful pending ops (file-based only)**: Conflict dialog if real user data at risk
|
||||
|
||||
## D. SYNC_IMPORT (full state replacement)
|
||||
|
||||
- **No local pending, has meaningful data**: Conflict dialog with import reason shown, "Use Server Data" recommended
|
||||
- **No local pending, no meaningful data**: Apply silently (no dialog)
|
||||
- **Has local pending**: Conflict dialog before processing (regardless of meaningful data)
|
||||
- **Piggybacked SYNC_IMPORT**: Same conflict dialog as download path — prevents silent state replacement
|
||||
- **Local import filters remote ops**: Conflict dialog (local created the import)
|
||||
- **Remote import filters remote ops**: Silent filter (import already accepted)
|
||||
- **Same-client pruning artifact**: Ops kept (can't conflict with own import)
|
||||
|
||||
## E. Encryption
|
||||
|
||||
- **Enable**: Delete server → upload encrypted snapshot. Other clients get password prompt.
|
||||
- **Disable**: Delete server → upload unencrypted. Other clients auto-detect.
|
||||
- **Change password**: Clean slate → new SYNC_IMPORT encrypted with new key
|
||||
- **Wrong password**: Error → password dialog (Save & Sync / Use Local Data)
|
||||
- **Mismatch (remote disabled)**: Auto-disable local encryption + snackbar
|
||||
- **Mandatory prompt**: After every unencrypted SuperSync sync until password set or sync disabled
|
||||
- **Blocks concurrent sync**: Encryption ops lock out sync until complete
|
||||
- **File import**: Preserves encryption state
|
||||
|
||||
## F. Server Migration
|
||||
|
||||
- **Empty server detected**: Auto-create SYNC_IMPORT from local state
|
||||
- **Race condition**: If server no longer empty, abort migration, sync normally
|
||||
|
||||
## G. Errors
|
||||
|
||||
- Network timeout → retry next sync
|
||||
- CORS → snackbar with details
|
||||
- Auth failure → clear creds, prompt reconfigure
|
||||
- Server error → silent retry
|
||||
- Duplicate op → mark synced silently
|
||||
- Storage quota → alert dialog
|
||||
- Schema too new → log warning, HANDLED_ERROR
|
||||
- Migration failure → skip failed ops, snackbar
|
||||
- Concurrent sync → second attempt blocked
|
||||
- App closes mid-sync → pending ops survive in IndexedDB
|
||||
|
||||
## H. Multi-Client
|
||||
|
||||
- **A encrypts, B has pending**: B gets conflict dialog (was previously broken — silent discard)
|
||||
- **A changes password, B has old**: B gets password dialog
|
||||
- **A imports file, B has changes**: B gets conflict dialog
|
||||
- **Both force-upload**: Last-write-wins at server level
|
||||
- **Three clients, normal edits**: LWW for same entity, clean merge for different entities
|
||||
|
||||
## I. Setup & Provider Switching
|
||||
|
||||
- **New user, empty server**: Setup → probe server (empty) → create-password prompt → done
|
||||
- **Existing local data, empty server**: Auto-create SYNC_IMPORT from local state (pre-op-log client fix)
|
||||
- **Second client, server has encrypted data**: Setup → probe server → enter-password prompt → download all
|
||||
- **Second client, server has unencrypted data**: Setup → probe server → create-password prompt → download all
|
||||
- **Second client with local data**: Full conflict dialog
|
||||
- **Re-enable after disable**: Seamless resume from stored lastServerSeq
|
||||
- **Switch accounts**: New lastServerSeq=0, server migration if empty server
|
||||
- **File-based → SuperSync**: Server migration uploads SYNC_IMPORT
|
||||
- **SuperSync → File-based**: Full state snapshot written to file
|
||||
- **Encrypted SuperSync → File-based**: Encryption is per-provider, WebDAV starts unencrypted
|
||||
- **Rapid switching**: Op log + vector clocks + client ID preserved across all switches
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. `syncedAt` is per-operation, not per-provider — ops won't re-upload after switching
|
||||
2. Encryption state may show misleading global config after provider switch
|
||||
3. No "skip encryption" for SuperSync — Cancel disables sync entirely
|
||||
894
docs/sync-and-op-log/supersync-scenarios.md
Normal file
894
docs/sync-and-op-log/supersync-scenarios.md
Normal file
|
|
@ -0,0 +1,894 @@
|
|||
# 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. `_hasMeaningfulLocalData()` = 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. `_hasMeaningfulLocalData()` = 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 `_hasMeaningfulLocalData()` (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 → 0, BUT check `_hasMeaningfulLocalData()` → true
|
||||
3. **Show conflict dialog** with `scenario: 'INCOMING_IMPORT'` and `syncImportReason` from the incoming op
|
||||
4. Dialog recommends "Use Server Data" (primary button) since this is an incoming import
|
||||
5. USE_LOCAL → `forceUploadLocalState()` (overrides remote with local data)
|
||||
6. USE_REMOTE → `forceDownloadRemoteState()` (clears local ops, downloads from seq 0)
|
||||
7. CANCEL → return with `cancelled: true`, skip upload phase
|
||||
8. If no meaningful local data → `processRemoteOps()` applies silently (no dialog)
|
||||
|
||||
**User sees:** Conflict dialog explaining the reason for the remote import (encryption change, file import, etc.) with "Use Server Data" recommended. If client has no meaningful data, data is replaced seamlessly.
|
||||
|
||||
### 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 (pending local ops > 0 OR `_hasMeaningfulLocalData()` = true):
|
||||
- **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 local data → `processRemoteOps()` applies silently (no dialog)
|
||||
|
||||
**Previously broken:** Piggybacked SYNC_IMPORTs went directly to `processRemoteOps()` without the conflict dialog check that the download path has, silently replacing local state.
|
||||
|
||||
**User sees:** Conflict dialog explaining remote import detected. Same UX as download-path SYNC_IMPORT.
|
||||
|
||||
---
|
||||
|
||||
## 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 `_hasMeaningfulLocalData()` = 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. `_hasMeaningfulLocalData()` = 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. `_hasMeaningfulLocalData()` = 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue