Bug fix:
- Fix vector clock cache staleness in multi-tab scenarios by clearing
cache when acquiring operation write lock. Each browser tab has its
own in-memory cache, so Tab B's cache could be stale if Tab A wrote
while Tab B was waiting for the lock.
Shared code extraction (client/server consistency):
- Extract vector clock comparison to @sp/shared-schema
- Client wraps shared impl with null handling
- Server imports directly from shared
- Extract entity types to @sp/shared-schema
- Single source of truth for ENTITY_TYPES array
- Removes duplicated "must match" comments
Files:
- packages/shared-schema/src/vector-clock.ts (new)
- packages/shared-schema/src/entity-types.ts (new)
- src/app/op-log/store/operation-log-store.service.ts (cache clear)
- src/app/op-log/capture/operation-log.effects.ts (call cache clear)
Fixes critical bug where multiple clients with existing data could each
create a SYNC_IMPORT when enabling SuperSync, causing data loss.
Root cause: When clients had local data (from legacy migration), each
would detect "server needs migration" and create competing SYNC_IMPORTs.
The last one uploaded would invalidate all prior data.
Fixes:
- Server rejects duplicate SYNC_IMPORT with SYNC_IMPORT_EXISTS (409)
- Client double-checks server is empty before creating SYNC_IMPORT
- Client merges all local op clocks into SYNC_IMPORT's vector clock
- hasSyncedOps() excludes MIGRATION/RECOVERY ops from check
- Upload service gracefully handles SYNC_IMPORT_EXISTS rejection
Tests added:
- ServerMigrationService: double-check and clock merging (5 tests)
- OperationLogStoreService: hasSyncedOps() MIGRATION exclusion (7 tests)
- OperationLogUploadService: SYNC_IMPORT_EXISTS handling (5 tests)
- E2E: Multiple clients with existing data merge correctly
The server was accepting empty string entityId values because the validation
only checked type and length, not content. This could allow corrupt operations
to be synced to other clients.
Changes:
- Add check for empty/whitespace-only entityId (returns INVALID_ENTITY_ID)
- Add 3 unit tests for empty, whitespace-only, and tab/newline entityId
- Update existing empty string test to expect INVALID_ENTITY_ID (format error)
instead of MISSING_ENTITY_ID (null/undefined check)
Root cause: Operations with `entityId: undefined` (corrupt ops) caused
an infinite rejection loop during sync:
1. Upload ops → Rejected with CONFLICT_CONCURRENT
2. Rejection handler creates merged ops (still with invalid entityId)
3. Upload merged ops → Rejected again
4. Loop continues forever
This prevented the sync status double checkmark from ever appearing
because hasPendingOps never became false.
Three-layer defense implemented:
1. Client: Guard time tracking dispatch (task.service.ts)
- Changed `if (currentTask &&` to `if (currentTask?.id &&`
- Prevents time tracking dispatch with undefined taskId
2. Client: Strengthen entityId validation (operation-log.effects.ts)
- Check that entityId is truthy AND a string type
- Log error with actual entityId value for debugging
3. Client: Cleanup corrupt ops on startup (operation-log-recovery.service.ts)
- New cleanupCorruptOps() method marks corrupt ops as rejected
- Runs during hydration to break existing infinite loops
- Excludes bulk 'ALL' operations which legitimately lack entityId
4. Server: Validate entityId for all regular operations (validation.service.ts)
- Reject ops with missing entityId (except full-state and bulk ops)
- Full-state: SYNC_IMPORT, BACKUP_IMPORT, REPAIR
- Bulk entity types: ALL, RECOVERY
- Returns MISSING_ENTITY_ID error code (permanent rejection)
When a duplicate operation was uploaded, PostgreSQL's unique constraint
violation (P2002) would abort the entire transaction, causing all
subsequent operations in the batch to fail with INTERNAL_ERROR.
Server-side fix:
- Add pre-check via findUnique before attempting insert
- Return DUPLICATE_OPERATION error code without aborting transaction
- Other ops in batch continue processing normally
Client-side fix:
- Handle DUPLICATE_OPERATION error code in rejected-ops-handler
- Mark duplicate as synced (not rejected) since server already has it
- Prevents infinite retry loop for legitimately uploaded ops
Adds tests for both server and client handling.
Optimize sync to reduce round-trips when remote changes are small:
- Increase PIGGYBACK_LIMIT from 100 to 500 ops on server
- Skip download call when server confirms hasMorePiggyback === false
- CRITICAL: Only skip when hasMorePiggyback is explicitly false, not undefined
(undefined means no upload happened, so we must still download)
This reduces sync from 2 round-trips to 1 when ≤500 ops are pending,
improving perceived sync performance.
Add 5 unit tests for the skip-download optimization logic.
- Add payload complexity validation for full-state ops (SYNC_IMPORT,
BACKUP_IMPORT, REPAIR) with higher thresholds (50 depth, 100k keys)
to prevent DoS attacks via deeply nested payloads
- Make persistence failure notification sticky (duration: 0) so users
don't miss critical errors that require page reload
- Fix misleading comment in gap detection (effectiveSinceSeq, not sinceSeq)
- Add comprehensive unit tests for StaleOperationResolverService (16 tests)
covering vector clock merging, timestamp preservation, and edge cases
Operations with timestamps beyond maxClockDriftMs (60s) were being
permanently rejected, causing silent data loss when a client's clock
was slightly ahead.
Now these timestamps are clamped to now + maxClockDriftMs and accepted.
A TIMESTAMP_CLAMPED audit log is recorded for debugging.
- Add clamping logic in SyncService.processOperation()
- Remove future timestamp rejection from ValidationService
- Add comprehensive tests verifying clamped values and boundaries
- Update integration test to expect acceptance
Add SYNC NOTE comments to both client and server compareVectorClocks
implementations pointing to each other. This prevents accidental drift
between the two implementations which must use identical algorithms.
Tombstones were used for tracking deleted entities but are no longer
needed with the operation log architecture. This removes:
- Tombstone table from Prisma schema
- Tombstone-related methods from SyncService
- Tombstone mocks and tests from all test files
- Database migration to drop tombstones table
The operation log now handles deletions through DEL operations,
making the separate tombstone tracking redundant.
- Replace opRetentionMs, maxOpAgeMs, and STALE_DEVICE_THRESHOLD_MS
with single retentionMs (45 days)
- Consolidate 5 cleanup jobs into 1 daily job
- Remove tombstone-related code and tests (tombstones were removed
in a previous migration)
- Simplifies configuration and removes contradictory values
(maxOpAgeMs was 30 days while opRetentionMs was 45 days)
Add comprehensive tests for error code handling at unit and E2E levels:
Unit tests (super-sync.spec.ts):
- 429 Rate Limited response handling
- 413 Storage Quota Exceeded response handling
- Parse CONFLICT_CONCURRENT/STALE/DUPLICATE_OPERATION from response
- Mixed accept/reject batch categorization
- Extract piggybacked ops from conflict response
Unit tests (operation-log-sync.service.spec.ts):
- STORAGE_QUOTA_EXCEEDED shows alert but doesn't mark op rejected
- INTERNAL_ERROR marks op as rejected
- Verify subsequent operations behavior after errors
E2E tests (supersync-error-handling.spec.ts):
- Concurrent modification triggers LWW conflict resolution
- Sync recovers after initial connection
- Duplicate sync attempts handled gracefully
- Three clients converge to same state
Also adds SnapshotService.clearForUser() helper for test cleanup.
Remove the parentOpId field from the operation log system. It was
designed for conflict resolution chain tracking but was never
implemented - the field was always undefined/null.
Client changes:
- Remove from Operation interface
- Remove from compact codec encoding/decoding
- Update tests
Server changes:
- Remove from Operation type and Zod validation
- Remove from persistence and download mapping
- Add database migration to drop the column
The snapshot route had a time-based TTL (5 minutes) that could return
stale snapshots even when fresher data was available. This was redundant
because generateSnapshot() already handles caching via sequence-based
freshness - it returns the cached snapshot if up-to-date and only
replays ops when new ones exist.
Remove snapshotCacheTtlMs from SyncConfig and simplify the GET /snapshot
route to just call generateSnapshot() directly.
Android WebView's fetch() corrupts binary Uint8Array bodies, causing
"incorrect header check" errors when decompressing gzip data on the
server. This fix uses CapacitorHttp with base64-encoded gzip on Android.
Client changes:
- Add isAndroidWebView getter for testability
- Add _fetchApiCompressedAndroid() method using CapacitorHttp
- Modify uploadOps() and uploadSnapshot() to use Android path
- Send Content-Transfer-Encoding: base64 header to signal encoding
Server changes:
- Add decompressBody() helper to handle base64+gzip
- Check Content-Transfer-Encoding header in /ops and /snapshot routes
- Decode base64 before gunzip when header is present
Net bandwidth savings ~50% (gzip saves 70%, base64 adds 33% overhead).
Includes comprehensive tests for both client and server paths.
Extract storage quota calculation and checking methods:
- calculateStorageUsage(): Calculate total storage from ops + snapshot
- checkStorageQuota(): Check if upload is within quota
- updateStorageUsage(): Update cached storage counter
- getStorageInfo(): Get storage stats for status endpoint
Complex cleanup operations (freeStorageForUpload, deleteOldestRestorePointAndOps)
remain in SyncService as they orchestrate multiple operations.
All 246 tests passing.
Continue SyncService extraction with two more services:
- DeviceService: device ownership and online status queries
(isDeviceOwner, getAllUserIds, getOnlineDeviceCount)
- OperationDownloadService: operation retrieval with gap detection
and snapshot optimization (getOpsSince, getOpsSinceWithSeq, getLatestSeq)
SyncService now delegates to 6 extracted services:
- ValidationService
- RateLimitService
- RequestDeduplicationService
- DeviceService
- OperationDownloadService
All 236 tests passing.
Extract three services from the monolithic SyncService for better
separation of concerns and testability:
- ValidationService: validates operations (validateOp, validatePayloadComplexity)
- RateLimitService: handles rate limiting with in-memory counters
- RequestDeduplicationService: handles request deduplication cache
Also migrate legacy tests from synchronous SQLite patterns to async
Prisma mocks:
- Create shared test state module (sync.service.test-state.ts)
- Migrate conflict-detection.spec.ts, gap-detection.spec.ts,
sync-operations.spec.ts to Prisma mocks
- Add comprehensive unit tests for extracted services
Test count: 227 passing (up from 164 before service extraction)
When storage quota is exceeded, the server now iteratively deletes
old restore points and operations until enough space is freed,
keeping at least one restore point as minimum valid sync state.
Changes:
- Add freeStorageForUpload() method that loops until quota satisfied
- Update deleteOldestRestorePointAndOps() to clear stale snapshot cache
- Update /ops and /snapshot routes to use iterative cleanup
- Add comprehensive tests for new cleanup behavior
The error response now includes cleanupStats showing what was freed
even when cleanup ultimately fails due to minimum data requirements.
Root cause: On Android, LockService was skipping locks entirely, allowing
ImmediateUploadService and main sync to upload the same operations
concurrently. This caused PostgreSQL serialization failures under
REPEATABLE_READ isolation.
Fixes:
1. LockService: Use fallback mutex on Electron/Android instead of no
locking. These are single-instance but still need in-process locking
for concurrent code paths.
2. Server: Add detection for serialization failures (Prisma P2034,
PostgreSQL 40001/40P01). These are logged as warnings with clearer
error messages for retry.
Combined with the previous commit that treats INTERNAL_ERROR as
transient (not permanent rejection), these errors will now be
automatically retried on next sync.
When server storage quota is exceeded:
1. Server-side auto-cleanup:
- Recalculate actual storage before failing (fixes stale cache issue)
- Delete oldest restore point and all ops before it to free space
- If only one restore point, keep it but delete ops before it
- Retry quota check after cleanup before returning error
2. Client-side strong warnings:
- Show window.alert when STORAGE_QUOTA_EXCEEDED error received
- Alert on both ops upload and snapshot upload failures
- Makes sync failure immediately visible to users
3. Improved error messaging in translations
Includes comprehensive unit tests for all cleanup scenarios.
Address two issues identified in code review:
1. Storage cache not refreshed after cleanup:
- Modified deleteOldSyncedOpsForAllUsers() to return { totalDeleted, affectedUserIds }
- Cleanup job now calls updateStorageUsage() for each affected user
- Prevents stale quota checks after nightly cleanup
2. serverTime missing from download response:
- Added serverTime: Date.now() to DownloadOpsResponse
- Enables client-side clock drift detection
Tests:
- Added serverTime response test in sync-fixes.spec.ts
- Added deleteOldSyncedOpsForAllUsers return structure tests
- Updated legacy SQLite tests for new return type (excluded from CI)
When syncing 100+ operations, the server's piggyback limit (100 ops)
was causing Client B to miss operations. The server returned 100 piggybacked
ops but latestSeq was set to the actual server sequence (e.g., 199).
Client B then updated lastServerSeq to 199, so subsequent download got 0 ops.
Changes:
- Server: Add hasMorePiggyback flag to UploadOpsResponse when piggyback limit
is reached and more ops exist
- Client: When hasMorePiggyback is true, store lastServerSeq as the max
piggybacked op's serverSeq instead of latestSeq, ensuring subsequent
download fetches remaining ops
- Effects: Change switchMap to mergeMap in autoAddTodayTagOnMarkAsDone to
ensure ALL mark-as-done actions trigger planTasksForToday
- Flush service: Implement two-phase wait strategy (poll queue + acquire lock)
to ensure all pending writes complete before upload
- Add diagnostic logging for operation counts at key stages
Test: High volume sync with 50 tasks + 49 mark-as-done (197 ops total)
now correctly syncs all done states via piggyback (100) + download (97).
Implements the ability to change the encryption password by deleting
all server data and uploading a fresh snapshot with the new password.
Server changes:
- Add DELETE /api/sync/data endpoint to delete all user sync data
- Add deleteAllUserData() method to SyncService
Client changes:
- Add deleteAllData() to OperationSyncCapable interface
- Implement deleteAllData() in SuperSync provider
- Add EncryptionPasswordChangeService to orchestrate password change
- Add DialogChangeEncryptionPasswordComponent with validation
- Add "Change Encryption Password" button to sync settings (visible
when encryption is enabled)
- Add translations for all new UI strings
Testing:
- Add 10 unit tests for EncryptionPasswordChangeService
- Add 14 unit tests for DialogChangeEncryptionPasswordComponent
- Add 5 E2E tests for complete password change flow
- Add changeEncryptionPassword() helper to SuperSyncPage
Also fixes:
- Add missing deleteAllData() to MockOperationSyncProvider
- Fix typo S_FINISH_DAY_SYNC_ERROR -> FINISH_DAY_SYNC_ERROR
1. CRITICAL: Encrypted ops blocking snapshot/restore
- Server now rejects restore requests when encrypted ops exist
- Added ENCRYPTED_OPS_NOT_SUPPORTED error code
- replayOpsToState defensively skips encrypted ops
2. HIGH: Dedup before quota check
- Moved request deduplication BEFORE quota check
- Ensures retries after successful uploads don't fail with 413
3. MEDIUM: Chunked uploads reusing lastKnownServerSeq
- Now updates lastKnownServerSeq between chunks
- Prevents duplicate piggybacked ops on subsequent chunks
4. MEDIUM: Vector-clock EQUAL from different client
- EQUAL clocks from different clients now treated as conflict
- Added explicit handling for all comparison cases
- Removed unsafe catch-all that returned hasConflict:false
5. LOW: SuperSync status localStorage scoping
- Added setScope/clearScope methods to SuperSyncStatusService
- Storage key now includes scope ID (hash of server+token)
- Prevents stale status when switching accounts/servers
Tests: Added unit tests for vector clock comparison and updated
all relevant test files with comprehensive coverage.
Fixes identified issues in the operation log sync system:
1. [High] Upload deduplication now returns fresh piggybacked ops on retry
- Deduplicated responses now use the retry request's lastKnownServerSeq
- Ensures clients don't miss operations from other clients
2. [High] Server conflict detection now checks all entityIds in batch ops
- Multi-entity operations (entityIds array) now properly checked
- Each entity ID is verified for concurrent modifications
3. [High] Encrypted snapshot uploads now properly flagged
- Added isPayloadEncrypted parameter to uploadSnapshot interface
- Server stores and returns the flag with operations
- Enables proper decryption on download
4. [Medium] clientId validation added to server
- Rejects operations where op.clientId doesn't match request clientId
- Returns INVALID_CLIENT_ID error code
5. [Low] Error classification now uses structured errorCode
- Client checks errorCode instead of string matching error messages
- More reliable conflict detection
Also adds test infrastructure for Prisma-based server tests:
- New vitest.config.ts with proper setup
- Test setup file with Prisma mocks
- sync-fixes.spec.ts with tests for issues 1 and 3
Note: Legacy SQLite-based tests are excluded pending migration to
async Prisma patterns. Client tests all pass (133/133).
ts-node is a devDependency and not available in production containers.
Updated tsconfig to compile scripts/ alongside src/, and changed npm
scripts to use compiled JS. Added monitor:dev for local development.
Add storage quota system to prevent single users from consuming
excessive disk space on the sync server.
Changes:
- Add storageQuotaBytes and storageUsedBytes fields to User model
- Add STORAGE_QUOTA_EXCEEDED error code for quota violations
- Add quota calculation and enforcement methods to SyncService
- Block uploads (POST /ops, POST /snapshot) when quota exceeded
- Allow downloads to continue when over quota (so users can delete data)
- Return storage usage/quota in GET /status response
Default quota: 100MB per user
When a fresh client downloads operations (sinceSeq=0), the server now
finds the latest full-state operation (SYNC_IMPORT, BACKUP_IMPORT, REPAIR)
and starts from there instead of from the beginning.
This optimization:
- Reduces bandwidth by not sending superseded operations
- Reduces server and client processing time
- Works with the client-side filtering fix (pre-import ops are filtered anyway)
The response now includes latestSnapshotSeq to indicate where the
effective state starts, which clients can use for optimization decisions.
Fix moveToArchive operations being rejected by server:
- deriveOpType() now uses action's declared opType instead of deriving from entity changes
- moveToArchive declares opType: Update but was being changed to Delete based on entity changes
- Server rejected because DEL operations require singular entityId, but bulk ops use entityIds
Additional sync improvements:
- Use OpLog.warn instead of devError for old snapshot format (expected transitional state)
- Add lock-based synchronization for conflict detection (replaces flushPendingWrites)
- Handle TODAY_TAG correctly when checking if client state is empty
- Add partial apply failure handling with validation and user notification
- Server: Add race condition prevention with REPEATABLE_READ isolation and re-check after seq allocation
- Server: Add concurrent snapshot generation lock to prevent duplicate work
Update tests to use LockService instead of OperationWriteFlushService
- Add server endpoints for restore points (GET /api/sync/restore-points, GET /api/sync/restore/:serverSeq)
- Add RestorePoint types and RestoreCapable interface
- Add getRestorePoints() and generateSnapshotAtSeq() to SyncService
- Add SuperSyncRestoreService for client-side restore orchestration
- Add DialogRestorePointComponent for restore point selection UI
- Add "Restore from History" button to sync settings (visible when Super Sync is active)
- Fix circular dependency in SyncSafetyBackupService using lazy injection
- Update docker-compose.yml to support local development (configurable NODE_ENV, PUBLIC_URL)
Problem: Importing backups (~8.5MB) failed with "Transaction rolled back
due to internal error". The Prisma transaction had no explicit timeout,
defaulting to 5 seconds which was insufficient for large JSON payloads.
The client then marked the operation as permanently rejected instead of
retrying.
Server-side fix:
- Add 60s timeout to uploadOps transaction (matching generateSnapshot)
- Improve error message to indicate timeout vs other errors
- Include actual error details for debugging
Client-side fix:
- Extend transient error detection to include server errors:
- "transaction rolled back"
- "internal server error"
- "server busy" / "please retry"
- HTTP 500/502/503/504 errors
- Transient errors are NOT marked as rejected, allowing retry
Tests:
- Add 6 tests for transient vs permanent error handling
- Verify network errors, timeouts, and server errors are retriable
- Verify validation errors remain permanently rejected
- Add isPayloadEncrypted field to Zod OperationSchema (root cause fix)
- Add isPayloadEncrypted column to Prisma schema with migration
- Store and return isPayloadEncrypted in server sync service
- Remove debug logging from encryption services
- Remove potential key exposure from DecryptNoPasswordError
- Remove unnecessary console.log in fallback decryption
- Hide duplicate Advanced encryption fields when SuperSync selected
- Enable previously skipped encryption E2E test
- Add data/ folder to gitignore
- Server: Detect when client's sinceSeq > latestSeq (server reset)
- Server: Detect when sinceSeq > 0 but latestSeq = 0 (empty server)
- Client: Handle gapDetected response by resetting lastServerSeq to 0
- Client: Re-download all ops from beginning when gap is detected
- Server: Increase max payload keys from 10000 to 20000
This fixes the issue where Client B had a stale lastServerSeq from a
previous session. After server reset or Client A's fresh sync, Client B
would request ops after its stale seq and get nothing, even though new
ops existed at lower sequence numbers.
SYNC_IMPORT, BACKUP_IMPORT, and REPAIR operations contain the entire
application state which naturally exceeds the normal complexity limits
(max depth 20, max keys 10000). These operations are already protected
by bodyLimit (30MB) so the DoS concern is addressed.
This fixes the error "Payload too complex" when importing data via sync.
Code review fixes:
- Fix download memory limit to correctly return success: false
- Add logging when vector clock entries are stripped during sanitization
- Add integration test for partial batch upload recovery (network failure)
parentOpId support for conflict resolution:
- Add parentOpId field to Operation type for conflict chain tracking
- Add parent_op_id column to operations table with migration
- Update sync routes schema to accept parentOpId
- Store and retrieve parentOpId in sync service
Also adds PLUGIN_USER_DATA and PLUGIN_METADATA to allowed entity types.