diff --git a/docs/long-term-plans/supersync-client-simplification.md b/docs/long-term-plans/supersync-client-simplification.md new file mode 100644 index 0000000000..3b6685cef6 --- /dev/null +++ b/docs/long-term-plans/supersync-client-simplification.md @@ -0,0 +1,879 @@ +# SuperSync Client Simplification Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reduce ~600-900 lines of duplicated/unnecessary code across the SuperSync client sync stack while improving type safety and maintainability. + +**Architecture:** The SuperSync client spans three layers — transport (`sync-providers/super-sync/`), sync orchestration (`op-log/sync/`), and application-level services (`imex/sync/`). Each task in this plan targets a specific duplication or architectural weakness, ordered by risk (low-risk first). + +**Tech Stack:** Angular 19, TypeScript strict, NgRx, Jasmine/Karma tests + +--- + +## Phase 1: Low-Risk, High-Reward (Pure Refactors, No Behavior Change) + +### Task 1: Unify 4 HTTP methods in `super-sync.ts` (~150 lines saved) + +**Context:** +Four nearly-identical HTTP methods exist in `SuperSyncProvider`: +- `_fetchApi` (lines 480-558) — web, JSON body +- `_fetchApiNative` (lines 564-615) — native (CapacitorHttp), JSON body +- `_fetchApiCompressed` (lines 621-695) — web, gzip body +- `_fetchApiCompressedNative` (lines 704-766) — native, gzip body + +The web methods share ~50 lines of identical AbortController setup, `!response.ok` check, slow-request logging (>30s), `_checkHttpStatus`, and error handling. The native methods share CapacitorHttp call pattern, status check, slow-request logging, and `_handleNativeRequestError`. + +**Files:** +- Modify: `src/app/op-log/sync-providers/super-sync/super-sync.ts` +- Test: `src/app/op-log/sync-providers/super-sync/super-sync.spec.ts` + +**Step 1: Read and run the existing tests** + +Run: `npm run test:file src/app/op-log/sync-providers/super-sync/super-sync.spec.ts` +Expected: All tests pass (establishes baseline). + +**Step 2: Extract `_doWebFetch()` from `_fetchApi` and `_fetchApiCompressed`** + +Replace `_fetchApi` and `_fetchApiCompressed` with a single unified method: + +```typescript +private async _doWebFetch( + url: string, + headers: Headers, + options: { method: string; body?: BodyInit }, +): Promise { + const startTime = Date.now(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SUPERSYNC_REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + ...options, + headers, + signal: controller.signal, + }); + + if (!response.ok) { + clearTimeout(timeoutId); + const errorText = await response.text().catch(() => 'Unknown error'); + this._checkHttpStatus(response.status, errorText); + throw new Error( + `SuperSync API error: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + // CRITICAL: Read response body BEFORE clearing timeout + const data = (await response.json()) as T; + clearTimeout(timeoutId); + + // Log slow requests + const duration = Date.now() - startTime; + if (duration > 30000) { + SyncLog.warn(this.logLabel, `Slow SuperSync request detected`, { + path: new URL(url).pathname, + durationMs: duration, + durationSec: (duration / 1000).toFixed(1), + }); + } + + return data; + } catch (error) { + clearTimeout(timeoutId); + const duration = Date.now() - startTime; + const path = new URL(url).pathname; + + if (error instanceof Error && error.name === 'AbortError') { + SyncLog.error(this.logLabel, `SuperSync request timeout`, { + path, + durationMs: duration, + timeoutMs: SUPERSYNC_REQUEST_TIMEOUT_MS, + }); + throw new Error( + `SuperSync request timeout after ${SUPERSYNC_REQUEST_TIMEOUT_MS / 1000}s: ${path}`, + ); + } + + SyncLog.error(this.logLabel, `SuperSync request failed`, { + path, + durationMs: duration, + error: (error as Error).message, + }); + throw error; + } +} +``` + +Then `_fetchApi` becomes: + +```typescript +private async _fetchApi( + cfg: SuperSyncPrivateCfg, + path: string, + options: RequestInit, +): Promise { + const baseUrl = this._resolveBaseUrl(cfg); + const url = `${baseUrl}${path}`; + const sanitizedToken = this._sanitizeToken(cfg.accessToken); + + if (this.isNativePlatform) { + return this._doNativeFetch(cfg, path, options.method || 'GET'); + } + + const headers = new Headers(options.headers as HeadersInit); + headers.set('Content-Type', 'application/json'); + headers.set('Authorization', `Bearer ${sanitizedToken}`); + + return this._doWebFetch(url, headers, { method: options.method || 'GET' }); +} +``` + +And `_fetchApiCompressed` becomes: + +```typescript +private async _fetchApiCompressed( + cfg: SuperSyncPrivateCfg, + path: string, + compressedBody: Uint8Array, +): Promise { + const baseUrl = this._resolveBaseUrl(cfg); + const url = `${baseUrl}${path}`; + const sanitizedToken = this._sanitizeToken(cfg.accessToken); + + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + headers.set('Content-Encoding', 'gzip'); + headers.set('Authorization', `Bearer ${sanitizedToken}`); + + return this._doWebFetch(url, headers, { + method: 'POST', + body: new Blob([compressedBody as BlobPart]), + }); +} +``` + +**Step 3: Extract `_doNativeFetch()` from `_fetchApiNative` and `_fetchApiCompressedNative`** + +```typescript +private async _doNativeFetch( + cfg: SuperSyncPrivateCfg, + path: string, + method: string, + requestData?: { data: string; extraHeaders?: Record }, +): Promise { + const startTime = Date.now(); + const baseUrl = this._resolveBaseUrl(cfg); + const url = `${baseUrl}${path}`; + const sanitizedToken = this._sanitizeToken(cfg.accessToken); + + const headers: Record = { + 'Authorization': `Bearer ${sanitizedToken}`, + 'Content-Type': 'application/json', + ...requestData?.extraHeaders, + }; + + try { + const response = await executeNativeRequestWithRetry( + { + url, + method, + headers, + data: requestData?.data, + connectTimeout: 10000, + readTimeout: SUPERSYNC_REQUEST_TIMEOUT_MS, + }, + this.logLabel, + ); + + if (response.status < 200 || response.status >= 300) { + const errorData = + typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data); + this._checkHttpStatus(response.status, errorData); + throw new Error(`SuperSync API error: ${response.status} - ${errorData}`); + } + + // Log slow requests + const duration = Date.now() - startTime; + if (duration > 30000) { + SyncLog.warn(this.logLabel, `Slow SuperSync request detected (native)`, { + path, + durationMs: duration, + durationSec: (duration / 1000).toFixed(1), + }); + } + + return response.data as T; + } catch (error) { + this._handleNativeRequestError(error, path, startTime); + } +} +``` + +Then `_fetchApiNative` is removed entirely (callers use `_doNativeFetch` directly), and `_fetchApiCompressedNative` becomes: + +```typescript +private async _fetchApiCompressedNative( + cfg: SuperSyncPrivateCfg, + path: string, + jsonPayload: string, +): Promise { + const base64Gzip = await compressWithGzipToString(jsonPayload); + + SyncLog.debug(this.logLabel, '_fetchApiCompressedNative', { + path, + originalSize: jsonPayload.length, + compressedBase64Size: base64Gzip.length, + }); + + return this._doNativeFetch(cfg, path, 'POST', { + data: base64Gzip, + extraHeaders: { + 'Content-Encoding': 'gzip', + 'Content-Transfer-Encoding': 'base64', + }, + }); +} +``` + +**Step 4: Run tests** + +Run: `npm run test:file src/app/op-log/sync-providers/super-sync/super-sync.spec.ts` +Expected: All tests pass. + +**Step 5: Lint and format** + +Run: `npm run checkFile src/app/op-log/sync-providers/super-sync/super-sync.ts` +Expected: No errors. + +**Step 6: Commit** + +```bash +git add src/app/op-log/sync-providers/super-sync/super-sync.ts +git commit -m "refactor(sync): unify duplicated HTTP methods in SuperSyncProvider" +``` + +--- + +### Task 2: Remove encryption injection tokens — use `spyOn` in tests (~74 lines saved) + +**Context:** +Five `InjectionToken` wrappers exist in `encryption.token.ts` solely for test mocking. The real functions (`encrypt`, `decrypt`, etc.) are module-level exports from `encryption.ts`. Tests can use `spyOn` on the module instead. + +Only one service (`OperationEncryptionService`) injects these tokens. The integration test and the spec file also reference them. + +**Files:** +- Delete: `src/app/op-log/encryption/encryption.token.ts` +- Modify: `src/app/op-log/sync/operation-encryption.service.ts` +- Modify: `src/app/op-log/sync/operation-encryption.service.spec.ts` +- Modify: `src/app/op-log/testing/integration/service-logic.integration.spec.ts` + +**Step 1: Run existing tests** + +Run: `npm run test:file src/app/op-log/sync/operation-encryption.service.spec.ts` +Expected: All pass. + +**Step 2: Modify `OperationEncryptionService` to import functions directly** + +Replace token injection with direct imports: + +```typescript +// Before: +import { ENCRYPT_FN, DECRYPT_FN, ENCRYPT_BATCH_FN, DECRYPT_BATCH_FN } from '../encryption/encryption.token'; + +// After: +import { encrypt, decrypt, encryptBatch, decryptBatch } from '../encryption/encryption'; +``` + +Replace the inject calls in the class: + +```typescript +// Before: +private readonly _encrypt = inject(ENCRYPT_FN); +private readonly _decrypt = inject(DECRYPT_FN); +private readonly _encryptBatch = inject(ENCRYPT_BATCH_FN); +private readonly _decryptBatch = inject(DECRYPT_BATCH_FN); + +// After: use the module functions directly throughout the class +// Replace this._encrypt(...) with encrypt(...) +// Replace this._decrypt(...) with decrypt(...) +// Replace this._encryptBatch(...) with encryptBatch(...) +// Replace this._decryptBatch(...) with decryptBatch(...) +``` + +**Step 3: Update the spec file to use `spyOn` on the module** + +In `operation-encryption.service.spec.ts`, replace token providers with `spyOn`: + +```typescript +import * as encryptionModule from '../encryption/encryption'; + +// In beforeEach: +spyOn(encryptionModule, 'encrypt').and.callFake(async (data: string) => `encrypted:${data}`); +spyOn(encryptionModule, 'decrypt').and.callFake(async (data: string) => data.replace('encrypted:', '')); +spyOn(encryptionModule, 'encryptBatch').and.callFake(async (items: string[]) => items.map(d => `encrypted:${d}`)); +spyOn(encryptionModule, 'decryptBatch').and.callFake(async (items: string[]) => items.map(d => d.replace('encrypted:', ''))); +``` + +Remove the token providers from TestBed. + +**Step 4: Update integration spec similarly** + +In `service-logic.integration.spec.ts`, replace token providers with `spyOn` on the module. + +**Step 5: Delete `encryption.token.ts`** + +Remove the file entirely. + +**Step 6: Run tests** + +Run: `npm run test:file src/app/op-log/sync/operation-encryption.service.spec.ts` +Run: `npm run test:file src/app/op-log/testing/integration/service-logic.integration.spec.ts` +Expected: All pass. + +**Step 7: Lint** + +Run: `npm run checkFile src/app/op-log/sync/operation-encryption.service.ts` +Expected: No errors. + +**Step 8: Commit** + +```bash +git add -u +git commit -m "refactor(sync): remove encryption injection tokens, use spyOn in tests" +``` + +--- + +### Task 3: Remove `DerivedKeyCacheService` wrapper (~33 lines saved) + +**Context:** +`DerivedKeyCacheService` is a 33-line Angular service that only wraps two module-level functions: `clearSessionKeyCache()` and `getSessionKeyCacheStats()` from `encryption.ts`. Callers can import and call these directly. + +**Consumers (6 files):** +- `src/app/imex/sync/file-based-encryption.service.ts` +- `src/app/imex/sync/file-based-encryption.service.spec.ts` +- `src/app/imex/sync/encryption-password-change.service.ts` +- `src/app/imex/sync/encryption-password-change.service.spec.ts` +- `src/app/imex/sync/sync-config.service.ts` + +**Files:** +- Delete: `src/app/op-log/encryption/derived-key-cache.service.ts` +- Modify: each consumer listed above + +**Step 1: Run existing tests for all consumers** + +Run: +```bash +npm run test:file src/app/imex/sync/file-based-encryption.service.spec.ts +npm run test:file src/app/imex/sync/encryption-password-change.service.spec.ts +``` +Expected: All pass. + +**Step 2: In each consumer service, replace `DerivedKeyCacheService` with direct imports** + +For each service file: + +```typescript +// Before: +import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service'; +// ... +private _derivedKeyCache = inject(DerivedKeyCacheService); +// ... +this._derivedKeyCache.clearCache(); + +// After: +import { clearSessionKeyCache } from '../../op-log/encryption/encryption'; +// ... +// (remove the inject) +// ... +clearSessionKeyCache(); +``` + +Do the same for `getCacheStats()` → `getSessionKeyCacheStats()` if used. + +**Step 3: Update spec files** + +In spec files, replace the `DerivedKeyCacheService` spy object with `spyOn(encryptionModule, 'clearSessionKeyCache')`. + +**Step 4: Delete `derived-key-cache.service.ts`** + +**Step 5: Run tests for all modified files** + +Run: +```bash +npm run test:file src/app/imex/sync/file-based-encryption.service.spec.ts +npm run test:file src/app/imex/sync/encryption-password-change.service.spec.ts +``` +Expected: All pass. + +**Step 6: Lint all modified files** + +Run: `npm run checkFile` on each modified `.ts` file. + +**Step 7: Commit** + +```bash +git add -u +git commit -m "refactor(sync): remove DerivedKeyCacheService wrapper, use direct imports" +``` + +--- + +### Task 4: Cache `_getServerSeqKey()` result in SuperSync (~10 lines) + +**Context:** +`super-sync.ts` calls `privateCfg.load()` in both `_cfgOrError()` and `_getServerSeqKey()` during every upload/download cycle. The key uses a djb2 hash of `${baseUrl}|${accessToken}` which doesn't change within a session. Caching it avoids redundant computation. + +**Files:** +- Modify: `src/app/op-log/sync-providers/super-sync/super-sync.ts` +- Test: `src/app/op-log/sync-providers/super-sync/super-sync.spec.ts` + +**Step 1: Add cache field and invalidation** + +```typescript +// Add field: +private _cachedServerSeqKey: string | null = null; + +// Modify _getServerSeqKey to cache: +private async _getServerSeqKey(): Promise { + if (this._cachedServerSeqKey) { + return this._cachedServerSeqKey; + } + const cfg = await this.privateCfg.load(); + const baseUrl = cfg?.baseUrl || SUPER_SYNC_DEFAULT_BASE_URL; + const accessToken = cfg?.accessToken ?? ''; + const identifier = `${baseUrl}|${accessToken}`; + const hash = identifier + .split('') + .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) + .toString(16); + this._cachedServerSeqKey = `${LAST_SERVER_SEQ_KEY_PREFIX}${hash}`; + return this._cachedServerSeqKey; +} + +// Invalidate in setPrivateCfg: +async setPrivateCfg(cfg: SuperSyncPrivateCfg): Promise { + this._cachedServerSeqKey = null; + await this.privateCfg.setComplete(cfg); +} +``` + +**Step 2: Run tests** + +Run: `npm run test:file src/app/op-log/sync-providers/super-sync/super-sync.spec.ts` +Expected: All pass. + +**Step 3: Lint and commit** + +```bash +npm run checkFile src/app/op-log/sync-providers/super-sync/super-sync.ts +git add src/app/op-log/sync-providers/super-sync/super-sync.ts +git commit -m "perf(sync): cache _getServerSeqKey() result in SuperSyncProvider" +``` + +--- + +## Phase 2: Medium-Risk Architectural Improvements + +### Task 5: Split `SyncProviderServiceInterface` — remove file-op stubs from SuperSync (~40 lines + cleaner types) + +**Context:** +SuperSync implements `SyncProviderServiceInterface` which requires 5 file operations (`getFileRev`, `downloadFile`, `uploadFile`, `removeFile`, `listFiles`) — all throw `Error('SuperSync uses operation-based sync only')`. The interface should be split so SuperSync only implements what it actually supports. + +**Files:** +- Modify: `src/app/op-log/sync-providers/provider.interface.ts` +- Modify: `src/app/op-log/sync-providers/super-sync/super-sync.ts` +- Modify: `src/app/op-log/sync-providers/provider-manager.service.ts` (and any other files referencing `SyncProviderServiceInterface` with SuperSync) +- Possibly modify: files that use `SYNC_PROVIDERS` array or type-narrow providers + +**Step 1: Define the new split interfaces** + +In `provider.interface.ts`, extract shared properties into a base: + +```typescript +/** + * Base sync provider properties shared by all provider types. + */ +export interface SyncProviderBase { + id: PID; + isUploadForcePossible?: boolean; + maxConcurrentRequests: number; + privateCfg: SyncCredentialStore; + isReady(): Promise; + getAuthHelper?(): Promise; + setPrivateCfg(privateCfg: PrivateCfgByProviderId): Promise; + clearAuthCredentials?(): Promise; +} + +/** + * File-based sync provider (Dropbox, WebDAV, LocalFile). + */ +export interface FileSyncProvider extends SyncProviderBase { + isLimitedToSingleFileSync?: boolean; + getFileRev(targetPath: string, localRev: string | null): Promise; + downloadFile(targetPath: string): Promise; + uploadFile(targetPath: string, dataStr: string, revToMatch: string | null, isForceOverwrite?: boolean): Promise; + removeFile(targetPath: string): Promise; + listFiles?(targetPath: string): Promise; +} + +// Keep SyncProviderServiceInterface as alias for backward compat during migration: +export type SyncProviderServiceInterface = FileSyncProvider; +``` + +**Step 2: Make SuperSync implement `SyncProviderBase` + `OperationSyncCapable` + `RestoreCapable`** + +Remove the 5 dead file-operation stubs (lines 106-144). + +**Step 3: Update `provider-manager.service.ts` and `SYNC_PROVIDERS`** + +The `SYNC_PROVIDERS` array type becomes `SyncProviderBase[]`. Any code that needs file operations narrows via a type guard: + +```typescript +export const isFileSyncProvider = ( + provider: SyncProviderBase, +): provider is FileSyncProvider => { + return 'getFileRev' in provider && typeof (provider as any).getFileRev === 'function'; +}; +``` + +**Step 4: Search for all references to `SyncProviderServiceInterface` and update** + +Run `grep -r 'SyncProviderServiceInterface' src/` to find all references. Each reference either: +- Works with any provider → change to `SyncProviderBase` +- Needs file operations → change to `FileSyncProvider` or add a type guard +- Already checks `isOperationSyncCapable` → leave as is, update type + +**Step 5: Run full test suite** + +Run: `npm test` +Expected: All pass. This is a cross-cutting type change so run the full suite. + +**Step 6: Lint and commit** + +```bash +npm run lint +git add -u +git commit -m "refactor(sync): split SyncProviderServiceInterface, remove dead file-op stubs from SuperSync" +``` + +--- + +### Task 6: Merge encryption toggle and import handler patterns (~100 lines saved) + +**Context:** +`SuperSyncEncryptionToggleService.enableEncryption` and `ImportEncryptionHandlerService.handleEncryptionStateChange` follow the exact same pattern: +1. `gatherSnapshotData()` +2. `isCryptoSubtleAvailable()` check +3. `encryptPayload()` (if enabling) +4. `syncProvider.deleteAllData()` +5. Update provider config +6. Upload snapshot +7. `updateLastServerSeq()` + +The only differences: error handling (toggle throws; import returns result), and config revert on failure (toggle reverts; import does not). + +**Approach:** Add a `deleteAndReupload()` method to `SnapshotUploadService` that encapsulates steps 1-7. Both callers invoke it with their specific options. + +**Files:** +- Modify: `src/app/imex/sync/snapshot-upload.service.ts` +- Modify: `src/app/imex/sync/supersync-encryption-toggle.service.ts` +- Modify: `src/app/imex/sync/import-encryption-handler.service.ts` +- Test: existing spec files for both services + +**Step 1: Run existing tests** + +Run: +```bash +npm run test:file src/app/imex/sync/supersync-encryption-toggle.service.spec.ts +npm run test:file src/app/imex/sync/import-encryption-handler.service.spec.ts +``` +Expected: All pass. + +**Step 2: Add `deleteAndReuploadWithNewEncryption()` to `SnapshotUploadService`** + +```typescript +/** + * Deletes all server data and uploads a fresh snapshot with new encryption settings. + * Common pattern used by both encryption toggle and import encryption handler. + */ +async deleteAndReuploadWithNewEncryption(options: { + encryptKey: string | undefined; + isEncryptionEnabled: boolean; + logPrefix: string; +}): Promise { + const { encryptKey, isEncryptionEnabled, logPrefix } = options; + + // Validate crypto availability before destructive action + if (isEncryptionEnabled && !isCryptoSubtleAvailable()) { + throw new WebCryptoNotAvailableError( + 'Cannot enable encryption: WebCrypto API is not available.', + ); + } + + const { syncProvider, existingCfg, state, vectorClock, clientId } = + await this.gatherSnapshotData(logPrefix); + + // Encrypt before delete (fail-early) + let payload: unknown = state; + if (isEncryptionEnabled && encryptKey) { + payload = await this._encryptionService.encryptPayload(state, encryptKey); + } + + // Delete all server data + await syncProvider.deleteAllData(); + + // Update config before upload + await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, { + ...existingCfg, + encryptKey: isEncryptionEnabled ? encryptKey : undefined, + isEncryptionEnabled, + } as SuperSyncPrivateCfg); + + // Upload snapshot + const result = await this.uploadSnapshot( + syncProvider, + payload, + clientId, + vectorClock, + isEncryptionEnabled && !!encryptKey, + ); + + if (result.accepted) { + await this.updateLastServerSeq(syncProvider, result.serverSeq, logPrefix); + } + + return { ...result, existingCfg }; +} +``` + +This requires injecting `OperationEncryptionService` and `SyncProviderManager` into `SnapshotUploadService`. + +**Step 3: Simplify `SuperSyncEncryptionToggleService.enableEncryption`** + +```typescript +async enableEncryption(encryptKey: string): Promise { + if (!encryptKey) throw new Error('Encryption key is required'); + + // Guard against concurrent calls + const activeProvider = this._providerManager.getActiveProvider(); + if (activeProvider) { + const currentCfg = await activeProvider.privateCfg.load() as { isEncryptionEnabled?: boolean; encryptKey?: string } | undefined; + if (currentCfg?.isEncryptionEnabled && currentCfg?.encryptKey) return; + } + + try { + const result = await this._snapshotUploadService.deleteAndReuploadWithNewEncryption({ + encryptKey, + isEncryptionEnabled: true, + logPrefix: LOG_PREFIX, + }); + if (!result.accepted) throw new Error(`Snapshot upload failed: ${result.error}`); + this._wrappedProviderService.clearCache(); + } catch (error) { + // Revert config on failure + // ... (revert logic stays here — specific to toggle service) + throw error; + } +} +``` + +**Step 4: Simplify `ImportEncryptionHandlerService.handleEncryptionStateChange`** + +```typescript +async handleEncryptionStateChange(...): Promise { + try { + const result = await this._snapshotUploadService.deleteAndReuploadWithNewEncryption({ + encryptKey: isEncryptionEnabled ? newEncryptKey : undefined, + isEncryptionEnabled, + logPrefix: LOG_PREFIX, + }); + if (!result.accepted) throw new Error(`Snapshot upload failed: ${result.error}`); + return { encryptionStateChanged: true, serverDataDeleted: true, snapshotUploaded: true }; + } catch (error) { + return { encryptionStateChanged: true, serverDataDeleted: false, snapshotUploaded: false, error: ... }; + } +} +``` + +**Step 5: Run tests** + +Run: +```bash +npm run test:file src/app/imex/sync/supersync-encryption-toggle.service.spec.ts +npm run test:file src/app/imex/sync/import-encryption-handler.service.spec.ts +npm run test:file src/app/imex/sync/snapshot-upload.service.spec.ts +``` +Expected: All pass. + +**Step 6: Lint and commit** + +```bash +npm run checkFile src/app/imex/sync/snapshot-upload.service.ts +npm run checkFile src/app/imex/sync/supersync-encryption-toggle.service.ts +npm run checkFile src/app/imex/sync/import-encryption-handler.service.ts +git add -u +git commit -m "refactor(sync): extract shared delete-and-reupload pattern into SnapshotUploadService" +``` + +--- + +### Task 7: Decompose `operation-log-sync.service.ts` — extract helper methods (~100-200 lines) + +**Context:** +At 1,106 lines with 13 injected dependencies, this is the largest orchestrator. Specific extraction targets: + +1. **SYNC_IMPORT conflict dialog logic** — duplicated between upload and download paths. Extract `_handleSyncImportConflict()`. +2. **`_hasMeaningfulLocalData()` + `_hasMeaningfulPendingOps()`** — always called together in 3 places. Extract `_hasAnyMeaningfulData(pendingOps)`. +3. **Server migration detection** — in both upload and download. Extract `_checkForServerMigration()`. + +**Files:** +- Modify: `src/app/op-log/sync/operation-log-sync.service.ts` +- Test: `src/app/op-log/sync/operation-log-sync.service.spec.ts` + +**Step 1: Run existing tests** + +Run: `npm run test:file src/app/op-log/sync/operation-log-sync.service.spec.ts` +Expected: All pass. + +**Step 2: Extract `_hasAnyMeaningfulData()`** + +Find the 3 places where `_hasMeaningfulLocalData()` and `_hasMeaningfulPendingOps()` are called together and replace with a single method: + +```typescript +private _hasAnyMeaningfulData(pendingOps: OperationLogEntry[]): boolean { + return this._hasMeaningfulPendingOps(pendingOps) || this._hasMeaningfulLocalData(); +} +``` + +**Step 3: Extract `_handleSyncImportConflict()` for shared dialog logic** + +Identify the SYNC_IMPORT dialog handling in both `uploadPendingOps` and `downloadRemoteOps` and extract the common pattern. + +**Step 4: Run tests after each extraction** + +Run: `npm run test:file src/app/op-log/sync/operation-log-sync.service.spec.ts` +Expected: All pass after each change. + +**Step 5: Lint and commit** + +```bash +npm run checkFile src/app/op-log/sync/operation-log-sync.service.ts +git add src/app/op-log/sync/operation-log-sync.service.ts +git commit -m "refactor(sync): extract helper methods from operation-log-sync.service.ts" +``` + +--- + +### Task 8: Auto-invalidate `WrappedProviderService` cache on credential changes (~20 lines saved, fragility removed) + +**Context:** +`WrappedProviderService._cache.clear()` is called manually in 5 different places across 3 services when encryption changes. This is fragile — new code paths that change encryption could forget to invalidate. + +**Approach:** Have `WrappedProviderService` subscribe to `SyncCredentialStore` changes and auto-invalidate. + +**Files:** +- Modify: `src/app/op-log/sync-providers/wrapped-provider.service.ts` +- Modify: callers that manually call `clearCache()` (remove those calls) +- Test: `src/app/op-log/sync-providers/wrapped-provider.service.spec.ts` + +**Step 1: Investigate how `SyncCredentialStore` notifies changes** + +Check if `SyncCredentialStore` has an observable or callback for changes. If not, add one. The simplest approach: have `SyncProviderManager.setProviderConfig()` emit a signal that `WrappedProviderService` subscribes to. + +**Step 2: Subscribe and auto-clear** + +```typescript +constructor() { + // Auto-invalidate cache when provider config changes + this._providerManager.providerConfigChanged$ + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cache.clear(); + OpLog.normal('WrappedProviderService: Cache auto-invalidated due to config change'); + }); +} +``` + +**Step 3: Remove manual `clearCache()` calls from consumers** + +Remove the explicit `this._wrappedProviderService.clearCache()` calls from: +- `supersync-encryption-toggle.service.ts` (3 occurrences) +- `import-encryption-handler.service.ts` +- `file-based-encryption.service.ts` +- `sync-config.service.ts` (3 occurrences) + +Keep the public `clearCache()` method as an escape hatch but mark it `@deprecated` with a note about auto-invalidation. + +**Step 4: Run full test suite** + +Run: `npm test` +Expected: All pass. + +**Step 5: Lint and commit** + +```bash +npm run lint +git add -u +git commit -m "refactor(sync): auto-invalidate WrappedProviderService cache on config changes" +``` + +--- + +## Phase 3: Lower Priority (Optional, Do If Time Permits) + +### Task 9: Extract conflict resolution entity-type strategies from `conflict-resolution.service.ts` + +**Context:** +At 1,324 lines, this file handles both conflict detection AND entity-type-specific LWW merge strategies. The entity-specific merge logic could be extracted into strategy objects per entity type, making the core service shorter and each strategy independently testable. + +**Scope:** This is a large refactor. Consider it a follow-up task. The current approach works; this is about maintainability. + +**Approach:** +1. Define a `ConflictResolutionStrategy` interface with a `resolve(local, remote): LWWResolution` method +2. Create one strategy per entity type in a `strategies/` subdirectory +3. The core service dispatches to the appropriate strategy based on `entityType` +4. Each strategy is independently testable + +**Files:** +- Create: `src/app/op-log/sync/strategies/` directory +- Modify: `src/app/op-log/sync/conflict-resolution.service.ts` + +This task should be done as a separate PR due to its scope. + +--- + +### Task 10: Consolidate encryption key storage (architectural — future consideration) + +**Context:** +The encryption key and `isEncryptionEnabled` flag exist in three places: +1. `SyncCredentialStore` (IndexedDB `sup-sync` database) — **the source of truth** +2. NgRx global config store (`globalConfig.sync.superSync`) — view model for the settings form +3. `WrappedProviderService` runtime config — derived from #1 and #2 + +Toggling encryption requires updating both #1 and #2. This dual-update pattern is a source of subtle bugs. + +**Long-term approach:** Make `SyncCredentialStore` the single source of truth. The NgRx sync config should derive encryption state from the credential store via an effect or signal, not store its own copy. + +**Risk:** High. This is a cross-cutting change that affects the settings form, sync initialization, and multiple services. Recommend doing this only after the above refactors are stable. + +--- + +## Summary + +| Task | Phase | Lines Saved | Risk | Dependencies | +|------|-------|-------------|------|--------------| +| 1. Unify HTTP methods | 1 | ~150 | Low | None | +| 2. Remove encryption tokens | 1 | ~74 | Low | None | +| 3. Remove DerivedKeyCacheService | 1 | ~33 | Low | None | +| 4. Cache server seq key | 1 | ~10 | Low | None | +| 5. Split provider interface | 2 | ~40 + types | Medium | None | +| 6. Merge encryption patterns | 2 | ~100 | Medium | None | +| 7. Decompose orchestrator | 2 | ~100-200 | Medium | None | +| 8. Auto-invalidate cache | 2 | ~20 | Medium | Task 3 done | +| 9. Extract conflict strategies | 3 | ~200 moved | Medium-High | None | +| 10. Consolidate key storage | 3 | ~50 + bugs | High | Task 8 done | +| **Total** | | **~600-900** | | | + +Tasks 1-4 are fully independent and can be done in any order (or in parallel via separate worktrees). Tasks 5-8 are also independent of each other but depend on Phase 1 being done. Tasks 9-10 are follow-up work. diff --git a/docs/sync-and-op-log/supersync-encryption-architecture.md b/docs/sync-and-op-log/supersync-encryption-architecture.md index c8d20c88af..283c7e54cd 100644 --- a/docs/sync-and-op-log/supersync-encryption-architecture.md +++ b/docs/sync-and-op-log/supersync-encryption-architecture.md @@ -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 ``` diff --git a/docs/sync-and-op-log/supersync-scenarios-flowchart.md b/docs/sync-and-op-log/supersync-scenarios-flowchart.md new file mode 100644 index 0000000000..d683fcfdde --- /dev/null +++ b/docs/sync-and-op-log/supersync-scenarios-flowchart.md @@ -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:
Download remote?] + LOCAL_DATA -->|Yes| CONFLICT_DLG[Conflict dialog:
USE_LOCAL / USE_REMOTE / CANCEL] + CONFIRM -->|OK| APPLY + CONFIRM -->|Cancel| CANCELLED([Sync Cancelled]) + CONFLICT_DLG -->|Use Local| FORCE_UP[Force upload local state
SYNC_IMPORT] + CONFLICT_DLG -->|Use Remote| FORCE_DL[Force download
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:
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
local data?} + MEANINGFUL -->|Yes| IMPORT_DLG[Conflict dialog:
import reason shown,
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
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
encryption?} + ENCRYPT_CHK -->|Yes| ENC_PROMPT[Encryption prompt:
Set password or disable sync] + ENCRYPT_CHK -->|No| IN_SYNC([IN_SYNC ✓]) + ENC_PROMPT -->|Password set| ENABLE_ENC[Enable encryption:
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 diff --git a/docs/sync-and-op-log/supersync-scenarios-simplified.md b/docs/sync-and-op-log/supersync-scenarios-simplified.md new file mode 100644 index 0000000000..337f016ac5 --- /dev/null +++ b/docs/sync-and-op-log/supersync-scenarios-simplified.md @@ -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 diff --git a/docs/sync-and-op-log/supersync-scenarios.md b/docs/sync-and-op-log/supersync-scenarios.md new file mode 100644 index 0000000000..4987d884d2 --- /dev/null +++ b/docs/sync-and-op-log/supersync-scenarios.md @@ -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.