Skip to main content

max / synckit-client

Add filtered pull, rich pull, subscribe, conflict resolution - pull_filtered / pull_filtered_rich: table + timestamp filtering - pull_rich / pull_filtered_rich: preserve device_id + seq metadata - Subscribe module (tokio-stream): poll-based change subscription - Conflict resolution module: last-write-wins + field-level merge - PullFilter, PulledChange, FilteredPullRequest types - Keychain error From impl, helpers cleanup, integration test refactor - Docs: audit_history, competition, integration_patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 22:21 UTC
Commit: 716f6af279f184c28d3c78f4c27042bd48137155
Parent: 9b41b39
19 files changed, +1592 insertions, -237 deletions
M Cargo.lock +12
@@ -1467,6 +1467,7 @@ dependencies = [
1467 1467 "serde_json",
1468 1468 "thiserror",
1469 1469 "tokio",
1470 + "tokio-stream",
1470 1471 "tracing",
1471 1472 "unicode-normalization",
1472 1473 "urlencoding",
@@ -1612,6 +1613,17 @@ dependencies = [
1612 1613 ]
1613 1614
1614 1615 [[package]]
1616 + name = "tokio-stream"
1617 + version = "0.1.18"
1618 + source = "registry+https://github.com/rust-lang/crates.io-index"
1619 + checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1620 + dependencies = [
1621 + "futures-core",
1622 + "pin-project-lite",
1623 + "tokio",
1624 + ]
1625 +
1626 + [[package]]
1615 1627 name = "tokio-util"
1616 1628 version = "0.7.18"
1617 1629 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1
@@ -21,6 +21,7 @@ zeroize = "1"
21 21 reqwest = { version = "0.12", features = ["json", "native-tls"] }
22 22 bytes = "1"
23 23 tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
24 + tokio-stream = "0.1"
24 25
25 26 # Serialization
26 27 serde = { version = "1", features = ["derive"] }
@@ -0,0 +1,101 @@
1 + # SyncKit Client SDK -- Audit History
2 +
3 + See [audit_review.md](./audit_review.md) for current scorecard and grades.
4 +
5 + ## Changes Since Last Audit
6 +
7 + ### Seventh audit (2026-03-28, Run 12 cross-project)
8 + - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures.
9 + - **Grade:** A (maintained). v0.3.0.
10 + - **No code changes since Run 9.**
11 + - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
12 + - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive.
13 + - **No new findings.** All previous items remain resolved.
14 +
15 + ### Rust Patterns Audit (2026-03-21)
16 + - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone
17 + - Auth request structs already use `&'a str` -- confirmed optimal, no change needed
18 +
19 + ### Sixth audit (2026-03-18, Run 9 cross-project)
20 + - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings.
21 + - **Grade:** A (maintained). v0.3.0.
22 + - **No new findings.** All previous items remain resolved.
23 + - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests.
24 + - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords.
25 + - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor.
26 +
27 + ### Concurrency Upgrade (2026-03-13)
28 + - **Concurrency:** B+ -> A-
29 + - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass.
30 +
31 + ### Second audit (2026-03-13, pre-launch skeptical lens)
32 + - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted.
33 + - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation)
34 + - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum
35 + - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate)
36 + - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt")
37 +
38 + ### Post-audit remediation (2026-03-13)
39 + - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred.
40 + - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests)
41 + - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag).
42 + - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk.
43 + - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place.
44 + - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries.
45 + - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines).
46 +
47 + ### Observability Upgrade (2026-03-13)
48 + - Added Observability dimension to scorecard (grade A)
49 + - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params
50 + - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data
51 + - `use tracing::instrument;` import added to client.rs
52 + - `cargo check` passes clean
53 +
54 + ### Performance Upgrade (2026-03-13)
55 + - **Performance:** B -> A-
56 + - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call
57 + - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites)
58 + - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition
59 +
60 + ### Resilience Upgrade (2026-03-13)
61 + - **Resilience:** B- -> A-
62 + - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip
63 + - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully
64 + - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest.
65 +
66 + ### Adversarial Test Audit (2026-03-13)
67 + - **Grade:** A- -> A-. Testing grade upgraded from B- to A.
68 + - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC.
69 + - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes.
70 + - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs.
71 + - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests.
72 + - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests.
73 + - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests.
74 + - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests.
75 + - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration.
76 + - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered.
77 +
78 + ### Third audit (2026-03-16, Run 6 cross-project)
79 + - **Test count:** 297 (unchanged)
80 + - **Grade:** A (maintained).
81 + - **Source LOC:** 4,327 src + 2,749 test
82 + - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop.
83 + - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml.
84 + - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM).
85 + - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta).
86 +
87 + ### Testing Push (2026-03-13)
88 + - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A.
89 + - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest.
90 + - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization.
91 + - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages.
92 + - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip.
93 + - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout).
94 + - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults.
95 +
96 + ### Performance Upgrade (2026-03-13)
97 + - **Performance:** A- -> A
98 + - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations
99 + - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT)
100 + - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call
101 + - All 297 tests pass unchanged (2 test assertions updated for Arc deref)
@@ -94,100 +94,6 @@ More critically, if `put_server_key` fails after the old envelope is fetched, th
94 94 | 2026-03-18 (Run 9) | 4,327 | 6 | 298 | ~69 |
95 95 | 2026-03-28 (Run 12) | 4,327 | 6 | 297 | ~69 |
96 96
97 - ## Changes Since Last Audit
98 -
99 - ### Seventh audit (2026-03-28, Run 12 cross-project)
100 - - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures.
101 - - **Grade:** A (maintained). v0.3.0.
102 - - **No code changes since Run 9.**
103 - - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
104 - - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive.
105 - - **No new findings.** All previous items remain resolved.
106 -
107 - ### Rust Patterns Audit (2026-03-21)
108 - - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone
109 - - Auth request structs already use `&'a str` -- confirmed optimal, no change needed
110 -
111 - ### Sixth audit (2026-03-18, Run 9 cross-project)
112 - - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings.
113 - - **Grade:** A (maintained). v0.3.0.
114 - - **No new findings.** All previous items remain resolved.
115 - - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests.
116 - - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords.
117 - - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor.
118 -
119 - ### Concurrency Upgrade (2026-03-13)
120 - - **Concurrency:** B+ -> A-
121 - - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass.
122 -
123 - ### Second audit (2026-03-13, pre-launch skeptical lens)
124 - - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted.
125 - - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation)
126 - - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum
127 - - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate)
128 - - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt")
129 -
130 - ### Post-audit remediation (2026-03-13)
131 - - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred.
132 - - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests)
133 - - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag).
134 - - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk.
135 - - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place.
136 - - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries.
137 - - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines).
138 -
139 - ### Observability Upgrade (2026-03-13)
140 - - Added Observability dimension to scorecard (grade A)
141 - - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params
142 - - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data
143 - - `use tracing::instrument;` import added to client.rs
144 - - `cargo check` passes clean
145 -
146 - ### Performance Upgrade (2026-03-13)
147 - - **Performance:** B -> A-
148 - - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call
149 - - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites)
150 - - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition
151 -
152 - ### Resilience Upgrade (2026-03-13)
153 - - **Resilience:** B- -> A-
154 - - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip
155 - - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully
156 - - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest.
157 -
158 - ### Adversarial Test Audit (2026-03-13)
159 - - **Grade:** A- -> A-. Testing grade upgraded from B- to A.
160 - - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC.
161 - - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes.
162 - - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs.
163 - - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests.
164 - - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests.
165 - - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests.
166 - - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests.
167 - - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration.
168 - - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered.
169 -
170 - ### Third audit (2026-03-16, Run 6 cross-project)
171 - - **Test count:** 297 (unchanged)
172 - - **Grade:** A (maintained).
173 - - **Source LOC:** 4,327 src + 2,749 test
174 - - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop.
175 - - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml.
176 - - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM).
177 - - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta).
178 -
179 - ### Testing Push (2026-03-13)
180 - - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A.
181 - - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest.
182 - - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization.
183 - - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages.
184 - - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip.
185 - - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout).
186 - - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults.
187 -
188 - ### Performance Upgrade (2026-03-13)
189 - - **Performance:** A- -> A
190 - - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations
191 - - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT)
192 - - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call
193 - - All 297 tests pass unchanged (2 test assertions updated for Arc deref)
97 + ---
98 +
99 + See [audit_history.md](./audit_history.md) for full chronological audit log.
@@ -0,0 +1,142 @@
1 + # SyncKit Client SDK -- Competitive Analysis
2 +
3 + Last updated: 2026-04-10
4 +
5 + ## Positioning
6 +
7 + SyncKit is an E2E encrypted, Rust-native, offline-first sync SDK for indie desktop and mobile apps. The server (hosted on MNW) stores only encrypted blobs -- zero-knowledge by design. Bundled with OTA updates and device management. Consumers: GoingsOn, Balanced Breakfast, audiofiles (all Tauri apps).
8 +
9 + The key differentiators are server-zero-knowledge encryption (XChaCha20-Poly1305 + Argon2id, keys never leave the device), opaque-blob storage (bring-your-own-schema, no server-side migrations), and the bundled OTA + device management layer that no sync competitor offers. Pricing is bundled with MNW creator tiers ($10-40/mo), not per-read/write metered.
10 +
11 + ## Pricing Comparison
12 +
13 + | Tool | Price | Model |
14 + |------|-------|-------|
15 + | **SyncKit** | $10-40/mo (bundled) | Included in MNW creator tier |
16 + | Firebase Firestore | Pay-per-use | $0.18/100K reads+writes, $0.26/GB |
17 + | Supabase | $0-$599/mo | Freemium + usage overages |
18 + | PowerSync | $0-$599/mo | Usage-based (GB synced) |
19 + | ElectricSQL | Pay-per-write | $1/M writes, reads free |
20 + | Turso | $0-$417/mo | Storage-based tiers |
21 + | Convex | $0-$25/member/mo | Freemium + usage overages |
22 + | Ditto | Enterprise (custom) | Sales-driven |
23 + | Couchbase Mobile | Enterprise (~25K+ EUR/yr) | License-based |
24 + | Etebase | Free (self-host) | Source-available, hosted beta |
25 +
26 + ## Feature Matrix
27 +
28 + | Feature | SyncKit | Firebase | Supabase | PowerSync | ElectricSQL | Ditto | Etebase |
29 + |---------|:-------:|:--------:|:--------:|:---------:|:-----------:|:-----:|:-------:|
30 + | E2E encrypted | Y | N | N | N | N | N | Y |
31 + | Server-zero-knowledge | Y | N | N | N | N | N | Y |
32 + | Rust SDK (native) | Y | N | N | Alpha | Y | Y | Y |
33 + | Tauri integration | Y | N | N | Alpha | N | N | N |
34 + | Offline-first | Y | Partial | N | Y | Partial | Y | Y |
35 + | Bring-your-own-schema | Y | N | N | N | N | N | Partial |
36 + | OTA updates | Y | N | N | N | N | N | N |
37 + | Device management | Y | N | N | N | N | N | N |
38 + | OS keychain storage | Y | N | N | N | N | N | N |
39 + | Blob/file sync | Y | Y | Y | N | N | N | Y |
40 + | Self-hostable | Y | N | Y | Y | Y | Y | Y |
41 + | Real-time push | N | Y | Y | Y | Y | Y | N |
42 + | P2P sync (no server) | N | N | N | N | N | Y | N |
43 + | CRDT conflict resolution | N | N | N | N | N | Y | N |
44 + | Rich query engine | N | Y | Y | Y | Y | Y | N |
45 +
46 + ## Competitor Deep Dives
47 +
48 + ### 1. Firebase (Google)
49 +
50 + Managed BaaS with Realtime Database and Firestore. Massive ecosystem (Auth, Functions, Hosting, Analytics). Generous free tier. Near-instant real-time push via persistent connections. No native Rust SDK (community crates are server-side only, not offline-capable).
51 +
52 + **What SyncKit lacks:** real-time push subscriptions, multi-platform mobile SDKs, hosted auth, serverless functions, web dashboard. **What Firebase lacks:** E2E encryption, Rust SDK, Tauri support, offline desktop sync, OTA updates, device management, data portability (complete vendor lock-in, no self-hosting).
53 +
54 + ### 2. Supabase
55 +
56 + Open-source Firebase alternative on PostgreSQL. Full SQL power, RLS for access control, self-hostable. Growing ecosystem. Realtime via Postgres CDC. No offline-first without PowerSync add-on.
57 +
58 + **What SyncKit lacks:** SQL query engine, built-in auth, edge functions, web dashboard, large community. **What Supabase lacks:** E2E encryption, offline-first (requires PowerSync add-on), Rust SDK, Tauri support, OTA updates, device management.
59 +
60 + ### 3. PowerSync -- Primary Threat
61 +
62 + Offline-first sync layer between your existing database and client-side SQLite. **Released a Tauri SDK (alpha, March 2026)** built on a Rust SDK. Works with Postgres, MongoDB, MySQL, SQL Server. Self-hostable Open Edition.
63 +
64 + **What SyncKit lacks:** multi-database source support, client-side SQL queries, partial replication (sync rules), larger team and community. **What PowerSync lacks:** E2E encryption (sync service sees all data), OTA updates, device management, blob/file sync, OS keychain integration. Write-path goes directly to your backend -- PowerSync does not handle write conflicts.
65 +
66 + PowerSync is the most direct competitor. If they add encryption, they become serious competition. Their Tauri SDK being alpha-quality is a window.
67 +
68 + ### 4. ElectricSQL
69 +
70 + Postgres CDC engine streaming "shapes" (filtered table subsets) to clients. Read-path only -- writes go through your own API. Open-source (Apache 2.0). Innovative pricing: writes cost money, reads/fan-out are free and unlimited. Rust client available.
71 +
72 + **What SyncKit lacks:** read-path fan-out, per-shape subscriptions, 10-language client support. **What ElectricSQL lacks:** E2E encryption, offline-first writes (no local write queue built in), OTA updates, device management, conflict resolution (your problem), blob sync.
73 +
74 + ### 5. Ditto
75 +
76 + Enterprise P2P sync with Bluetooth/WiFi Direct mesh networking. Rust core. CRDT-based automatic conflict resolution. $82M raised (March 2025). Targets airlines, military, retail.
77 +
78 + **What SyncKit lacks:** P2P mesh sync, CRDT conflict resolution, enterprise support. **What Ditto lacks:** E2E application-layer encryption, indie pricing (enterprise sales only), OTA updates, bring-your-own-schema (CRDTs need structure).
79 +
80 + ### 6. Couchbase Lite + Sync Gateway
81 +
82 + Enterprise mobile database with bidirectional sync. Battle-tested in large deployments. Gained momentum from MongoDB Realm shutdown (Sept 2025). Configurable conflict handlers. P2P sync between Couchbase Lite instances.
83 +
84 + **What SyncKit lacks:** P2P sync, rich on-device query engine, enterprise track record. **What Couchbase lacks:** E2E encryption, indie pricing (~25K EUR/yr), Rust SDK (experimental C bindings only), simplicity (multi-component architecture), OTA updates.
85 +
86 + ### 7. Etebase -- Philosophical Peer
87 +
88 + The only other E2E encrypted sync SDK with a Rust library. Open-source server, self-hostable. SDKs for Rust, JS, Java/Kotlin, Python, C, C#. Used by EteSync (contacts/calendar sync).
89 +
90 + **What SyncKit lacks:** broader language coverage (6 languages vs 1). **What Etebase lacks:** Tauri integration, OTA updates, device management, OS keychain, blob support via presigned URLs, commercial backing, community momentum (very small team, unclear trajectory).
91 +
92 + ### 8. Realm / Atlas Device Sync (MongoDB) -- Shut Down
93 +
94 + End-of-life as of September 30, 2025. MongoDB deprecated all Atlas Device SDKs. Developers displaced into Couchbase, Ditto, PowerSync, and ObjectBox. The shutdown created a significant gap in the offline-first sync market.
95 +
96 + ### 9. Others
97 +
98 + **Turso:** Edge SQLite replication. Read replicas only, writes go to primary. Cheap ($5/mo) but not a multi-device sync solution -- no bidirectional sync, no offline writes.
99 +
100 + **Convex:** Reactive backend with automatic query subscriptions. No offline support (requires internet). Recently open-sourced (BSL, converts to Apache 2.0 after 3 years). Rust client available but secondary to TypeScript.
101 +
102 + **CouchDB/PouchDB:** Document-oriented database with built-in sync protocol. Offline-first, conflict handling via revision trees. No E2E encryption. Mature but aging. JavaScript-focused.
103 +
104 + **Syncthing:** P2P file sync. E2E encrypted, no central server. Designed for folder/file sync, not structured app data. No changelog-based sync, no SDK API, no conflict resolution for structured data.
105 +
106 + **CRDT libraries (Automerge, Yjs, Loro):** Building blocks for conflict-free merge, not sync services. Handle data structure merging; bring-your-own transport/storage/auth. Incompatible with SyncKit's zero-knowledge model (server cannot merge what it cannot read).
107 +
108 + ## What We Offer That Competitors Don't
109 +
110 + - **Server-zero-knowledge** -- the server stores only encrypted blobs. No data breaches because there is no data to breach. Compliance-friendly (GDPR, NIS2).
111 + - **Bring-your-own-schema** -- table names, row IDs, and data shapes are opaque to the server. No server-side migrations when your app schema changes.
112 + - **Bundled OTA updates** -- Tauri-compatible auto-update protocol. No competitor offers sync + OTA in one SDK.
113 + - **Bundled device management** -- register, list, deregister devices. Track sync state per device.
114 + - **OS keychain integration** -- encryption keys stored in macOS Keychain, Linux Secret Service, or Windows Credential Manager. Key material never touches disk.
115 + - **Minimal blob overhead** -- binary files encrypted with only 40 bytes overhead (24-byte nonce + 16-byte auth tag). No base64 expansion.
116 + - **Key zeroization** -- `ZeroizeOnDrop` on all key material. No key residue in memory after use.
117 + - **Flat pricing** -- included in MNW creator tier. No per-read/write metering, no surprise bills.
118 +
119 + ## Market Tailwinds
120 +
121 + - **MongoDB Realm shutdown (Sept 2025)** displaced developers seeking offline-first sync alternatives
122 + - **Tauri adoption growing ~55% YoY**, creating demand for Rust-native backends
123 + - **Regulatory pressure (GDPR, NIS2)** pushing toward E2E encryption and data minimization
124 + - **Local-first movement** gaining mainstream traction (Notion, Linear, Figma adopting offline-first)
125 + - **PowerSync Tauri SDK is alpha** -- their Rust/Tauri story is immature, giving SyncKit a window
126 +
127 + ## Target Users
128 +
129 + - Indie developers building Tauri desktop apps who need cloud sync without running a backend
130 + - Developers who prioritize user privacy and want zero-knowledge sync by default
131 + - Small teams shipping cross-platform apps (macOS/Windows/Linux) that need offline-first data
132 + - Anyone displaced from MongoDB Realm looking for a simpler, encrypted alternative
133 +
134 + ## Gaps and Potential Roadmap Items
135 +
136 + Based on what competitors offer that SyncKit does not:
137 +
138 + - **Real-time push notifications** -- Firebase/Supabase/Convex push changes instantly. SyncKit is pull-based (clients poll). A lightweight SSE channel for "something changed, pull now" would close this gap without compromising E2E encryption (the notification carries no data, just a signal).
139 + - **Selective sync / sync rules** -- PowerSync and ElectricSQL let clients sync subsets of data. SyncKit syncs the full changelog. For apps with large datasets, filtered sync (by device, by date range, by collection) would reduce bandwidth and latency.
140 + - **Conflict resolution helpers** -- Ditto and Couchbase offer configurable merge strategies. SyncKit leaves conflict resolution to the client. A toolkit of common strategies (LWW, field-level merge, custom resolver callback) in the SDK would reduce boilerplate.
141 + - **Web client (WASM)** -- every major competitor has a JavaScript/TypeScript SDK. A WASM-compiled SyncKit client would open the web platform. Low priority (current consumers are all desktop), but relevant if any consumer app ships a web companion.
142 + - **Multi-language SDKs** -- Etebase covers 6 languages, PowerSync covers 10+. SyncKit is Rust-only. A C FFI layer would enable bindings for Swift, Kotlin, Python, and JS. Only worth doing if non-Tauri consumers appear.
@@ -0,0 +1,301 @@
1 + # SyncKit Client SDK -- Integration Patterns
2 +
3 + How GoingsOn, Balanced Breakfast, and audiofiles consume the SyncKit client SDK. Use this guide to add sync to a new app.
4 +
5 + ## Common Architecture
6 +
7 + All three apps follow the same pattern:
8 +
9 + ```
10 + App (SQLite)
11 + ├── Syncable tables with INSERT/UPDATE/DELETE triggers
12 + ├── sync_changelog table (captures local mutations)
13 + ├── sync_state table (key-value: device_id, cursor, flags)
14 + ├── Sync service module (push/pull logic)
15 + └── Scheduler (background timer, exponential backoff)
16 + ```
17 +
18 + ### Trigger-Based Changelog
19 +
20 + Each syncable table has SQL triggers that write to `sync_changelog`:
21 + - Triggers fire on INSERT, UPDATE, DELETE
22 + - When `applying_remote = '1'` in sync_state, triggers are suppressed (prevents echo-back)
23 + - Only whitelisted columns are included in the JSON payload
24 + - Row IDs are UUIDs (GO, BB) or content hashes (AF)
25 +
26 + ### Push/Pull Cycle
27 +
28 + 1. **Push**: Read unpushed changelog entries (batch 500) → encrypt via SDK → send to MNW → mark as pushed
29 + 2. **Pull**: Fetch remote changes from MNW → decrypt via SDK → apply locally with triggers suppressed → update cursor
30 + 3. **Cleanup**: Delete pushed entries older than 7 days
31 +
32 + ### FK-Safe Ordering
33 +
34 + Both push and pull use foreign-key-safe ordering:
35 + - **Upserts**: Parents first (projects before tasks, VFS before nodes)
36 + - **Deletes**: Children first (tasks before projects, nodes before VFS)
37 +
38 + ---
39 +
40 + ## Per-App Integration
41 +
42 + ### GoingsOn (13 tables)
43 +
44 + **Tables synced:**
45 + ```
46 + projects → milestones, tasks, events
47 + tasks → annotations, subtasks
48 + contacts → contact_emails, contact_phones, contact_social_handles, contact_custom_fields
49 + email_accounts (16 config columns only — credentials excluded)
50 + ```
51 +
52 + **Special handling:**
53 + - Email account passwords and OAuth tokens are excluded from the column whitelist — never leave the device
54 + - Tasks with `source_email_id` referencing unsynced emails: FK enforcement relaxed during remote apply
55 +
56 + **Location:** `src-tauri/src/sync_service.rs` (1814 lines, 43 tests)
57 +
58 + ### Balanced Breakfast (5 tables)
59 +
60 + **Tables synced:**
61 + ```
62 + feeds → feed_tags, query_feeds
63 + user_config
64 + feed_items (partial: is_read + is_starred only)
65 + ```
66 +
67 + **Special handling:**
68 + - `feed_items`: Uses UPDATE (not INSERT OR REPLACE) — only syncs user read/star state, never full content
69 + - `feed_items` deletes are ignored — content re-fetches from source feeds
70 + - Changelog retention cap: MAX_CHANGELOG_ENTRIES = 10,000 (prevents unbounded growth)
71 +
72 + **Location:** `src-tauri/src/sync_service.rs` (1062 lines, 30 tests)
73 +
74 + ### audiofiles (9 tables)
75 +
76 + **Tables synced:**
77 + ```
78 + vfs → vfs_nodes
79 + samples → audio_analysis, tags, collection_members
80 + collections → collection_members
81 + smart_folders
82 + user_config
83 + ```
84 +
85 + **Special handling — blob sync:**
86 + 1. VFS entries have a `sync_files` flag controlling blob sync
87 + 2. Samples marked `cloud_only = 1` if blob doesn't exist locally
88 + 3. After push/pull, upload pending blobs (local files in sync-enabled VFS)
89 + 4. Download missing blobs (cloud_only samples where file is needed)
90 +
91 + **Location:** `crates/audiofiles-sync/src/service.rs` (1438 lines, 48 tests)
92 +
93 + ---
94 +
95 + ## SDK Public API
96 +
97 + ### Authentication
98 +
99 + ```rust
100 + use synckit_client::SyncKitClient;
101 +
102 + // Create client
103 + let client = SyncKitClient::new(SyncKitConfig {
104 + server_url: "https://makenot.work".into(),
105 + api_key: "your-app-api-key".into(),
106 + });
107 +
108 + // OAuth2 PKCE flow
109 + let auth_url = client.build_authorize_url(port, state, code_challenge);
110 + // ... user completes browser flow ...
111 + let (user_id, app_id) = client.authenticate_with_code(code, code_verifier, port).await?;
112 +
113 + // Session management
114 + client.is_token_expired() -> bool
115 + client.session_info() -> Option<SessionInfo>
116 + client.clear_session() -> Result<()>
117 + ```
118 +
119 + ### Encryption Setup
120 +
121 + ```rust
122 + // First device: generate new master key
123 + client.setup_encryption_new(password).await?;
124 +
125 + // Subsequent devices: decrypt existing key from server
126 + client.setup_encryption_existing(password).await?;
127 +
128 + // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager)
129 + client.try_load_key_from_keychain().await? -> bool
130 +
131 + // Check if server has encrypted key (determines new vs existing flow)
132 + client.has_server_key().await? -> bool
133 + client.has_master_key() -> bool
134 + ```
135 +
136 + ### Device Management
137 +
138 + ```rust
139 + client.register_device(hostname, platform).await? -> Device
140 + client.list_devices().await? -> Vec<Device>
141 + ```
142 +
143 + ### Push/Pull
144 +
145 + ```rust
146 + use synckit_client::{ChangeEntry, ChangeOp};
147 +
148 + // Push encrypted changes to server
149 + client.push(device_id, changes: Vec<ChangeEntry>).await?;
150 +
151 + // Pull decrypted changes from server
152 + let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?;
153 + ```
154 +
155 + `ChangeEntry` fields:
156 + - `table`: Table name (string)
157 + - `op`: `ChangeOp::Insert`, `Update`, or `Delete`
158 + - `row_id`: Primary key (string)
159 + - `timestamp`: When the change was made
160 + - `data`: `Option<serde_json::Value>` (None for deletes)
161 +
162 + ### Blob Operations (audiofiles only)
163 +
164 + ```rust
165 + // Upload
166 + let resp = client.blob_upload_url(hash, size).await?;
167 + client.blob_upload(resp.url, data).await?;
168 + client.blob_confirm(hash, size).await?;
169 +
170 + // Download
171 + let url = client.blob_download_url(hash).await?;
172 + let data = client.blob_download(url).await?;
173 + ```
174 +
175 + ---
176 +
177 + ## First-Run Sequence
178 +
179 + ### First Device
180 +
181 + 1. User clicks "Connect to Sync"
182 + 2. App builds auth URL with PKCE challenge → opens browser
183 + 3. User logs in on MNW, approves scopes
184 + 4. Browser redirects to `localhost:PORT` with authorization code
185 + 5. App exchanges code for JWT via SDK
186 + 6. App detects no server key → shows "Set Encryption Password" dialog
187 + 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key
188 + 8. App registers device → stores device_id in sync_state
189 + 9. App creates initial snapshot (INSERT all existing rows to changelog)
190 + 10. First sync cycle runs
191 +
192 + ### Same Device, Later Run
193 +
194 + 1. App calls `try_load_key_from_keychain()` → restores session + key
195 + 2. If success, ready to sync
196 + 3. If keychain empty, re-run OAuth flow
197 +
198 + ### Additional Device
199 +
200 + 1. OAuth flow as above
201 + 2. App detects server has key → shows "Enter Encryption Password" dialog
202 + 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain
203 + 4. Register new device, create initial snapshot, sync
204 +
205 + ---
206 +
207 + ## Adding Sync to a New App
208 +
209 + ### 1. Database Schema
210 +
211 + Add these tables:
212 +
213 + ```sql
214 + CREATE TABLE sync_changelog (
215 + id INTEGER PRIMARY KEY AUTOINCREMENT,
216 + table_name TEXT NOT NULL,
217 + op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
218 + row_id TEXT NOT NULL,
219 + timestamp INTEGER NOT NULL,
220 + data TEXT, -- JSON, NULL for DELETE
221 + pushed INTEGER DEFAULT 0
222 + );
223 +
224 + CREATE TABLE sync_state (
225 + key TEXT PRIMARY KEY,
226 + value TEXT NOT NULL
227 + );
228 +
229 + -- Seed defaults
230 + INSERT INTO sync_state VALUES ('pull_cursor', '0');
231 + INSERT INTO sync_state VALUES ('applying_remote', '0');
232 + INSERT INTO sync_state VALUES ('initial_snapshot_done', '0');
233 + INSERT INTO sync_state VALUES ('auto_sync_enabled', '1');
234 + INSERT INTO sync_state VALUES ('sync_interval_minutes', '15');
235 + ```
236 +
237 + ### 2. Triggers
238 +
239 + For each syncable table, add three triggers:
240 +
241 + ```sql
242 + CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table
243 + BEGIN
244 + SELECT CASE
245 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
246 + THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data)
247 + VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'),
248 + json_object('col1', NEW.col1, 'col2', NEW.col2)))
249 + END;
250 + END;
251 + -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL)
252 + ```
253 +
254 + ### 3. Core Module
255 +
256 + Implement these functions:
257 +
258 + | Function | Purpose |
259 + |----------|---------|
260 + | `get_sync_state(key)` | Read from sync_state |
261 + | `set_sync_state(key, value)` | Write to sync_state |
262 + | `ensure_device_registered(client)` | Cache device_id after first registration |
263 + | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog |
264 + | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed |
265 + | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor |
266 + | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order |
267 + | `apply_delete(table, row_id)` | DELETE with FK order |
268 + | `cleanup_changelog()` | Prune pushed entries older than 7 days |
269 +
270 + ### 4. Scheduler
271 +
272 + - Check every 60 seconds if sync is due
273 + - On first run: `create_initial_snapshot()` if needed
274 + - Exponential backoff on failure (2^N minutes, capped at 15)
275 + - Emit status events for UI updates
276 +
277 + ### 5. Commands / UI
278 +
279 + Expose these to the frontend:
280 + - `sync_status()` — configured, authenticated, encryption, device, pending changes
281 + - `sync_start_auth()` — returns auth URL + state + verifier
282 + - `sync_complete_auth(code, state, verifier)` — exchanges for JWT
283 + - `sync_setup_encryption_new/existing(password)` — calls SDK method
284 + - `sync_now()` — triggers immediate cycle
285 + - `sync_disconnect()` — clears credentials and session
286 +
287 + ---
288 +
289 + ## Key Files
290 +
291 + | What | Where |
292 + |------|-------|
293 + | SDK source | `Shared/synckit-client/src/` |
294 + | SDK auth | `Shared/synckit-client/src/client/auth.rs` |
295 + | SDK push/pull | `Shared/synckit-client/src/client/sync.rs` |
296 + | SDK encryption | `Shared/synckit-client/src/crypto.rs` |
297 + | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` |
298 + | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` |
299 + | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` |
300 + | Server endpoints | `MNW/src/routes/synckit.rs` |
301 + | Server DB | `MNW/src/db/synckit.rs` |
M docs/todo.md +8 -7
@@ -1,18 +1,19 @@
1 1 # SyncKit Client SDK — Todo
2 2
3 - Done: All S1-S5 phases complete. All audit items resolved (Run 6 + Run 7). 297 tests + 1 doctest. client/ split into directory module (6 files).
3 + Done: All phases (S1-S5). Active: None. Next: Post-beta items below.
4 4
5 - Completed work archived in `docs/shared/synckit/audit_review.md` (Rust Patterns Audit section).
6 -
7 - ## Remaining
8 - - [ ] Write `competition.md` (competitive analysis vs Firebase, Supabase, Realm, CloudKit, etc.)
5 + v0.3.0. Audit grade A. 304 tests.
9 6
10 7 ## Deferred (Post-Beta)
8 + - [ ] Conflict resolution helpers — LWW, field-level merge, custom resolver callback in the SDK. Reduces client-side boilerplate. (Gap vs Ditto, Couchbase)
11 9 - [ ] Key rotation mechanism (requires server-side re-encryption of all sync_log entries)
12 10
11 + - [ ] WASM web client — compile SyncKit to WASM for browser use. Only if a consumer app ships a web companion.
12 + - [ ] C FFI layer — enables Swift/Kotlin/Python bindings. Only if non-Tauri consumers appear.
13 +
13 14 ## Key Paths
14 - - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, blob, helpers)
15 + - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, subscribe, blob, helpers)
15 16 - Crypto: `Shared/synckit-client/src/crypto.rs`
16 - - Types: `Shared/synckit-client/src/types.rs`
17 + - Types: `Shared/synckit-client/src/types.rs` (includes PullFilter, FilteredPullRequest)
17 18 - Keystore: `Shared/synckit-client/src/keystore.rs`
18 19 - Tests: `Shared/synckit-client/tests/integration.rs`
M src/client/auth.rs +25 -27
@@ -60,7 +60,7 @@ impl SyncKitClient {
60 60 ///
61 61 /// Sets the internal session state without making any HTTP calls.
62 62 /// Used on app startup to restore from stored credentials without re-authenticating.
63 - pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) -> Result<()> {
63 + pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) {
64 64 let token_exp = jwt_exp(token);
65 65 *self.session.write() = Some(Session {
66 66 token: Arc::new(token.to_string()),
@@ -69,7 +69,6 @@ impl SyncKitClient {
69 69 app_id,
70 70 });
71 71 tracing::info!("Session restored for user {user_id}, app {app_id}");
72 - Ok(())
73 72 }
74 73
75 74 /// Clear the in-memory session and master key.
@@ -77,11 +76,10 @@ impl SyncKitClient {
77 76 /// After calling this, the client will need to re-authenticate and set up
78 77 /// encryption again. Does not affect OS keychain storage — call
79 78 /// `keystore::delete_master_key` separately if needed.
80 - pub fn clear_session(&self) -> Result<()> {
79 + pub fn clear_session(&self) {
81 80 *self.session.write() = None;
82 81 *self.master_key.write() = None;
83 82 tracing::info!("Session and master key cleared");
84 - Ok(())
85 83 }
86 84
87 85 /// Check whether the current session token has expired (or will expire
@@ -89,17 +87,17 @@ impl SyncKitClient {
89 87 /// if the token's `exp` claim is in the past. Returns `false` if the
90 88 /// token cannot be decoded (assumes not expired — the server will reject
91 89 /// it with a 401 if it actually is).
92 - pub fn is_token_expired(&self) -> Result<bool> {
90 + pub fn is_token_expired(&self) -> bool {
93 91 let guard = self.session.read();
94 92 let Some(session) = guard.as_ref() else {
95 - return Ok(true);
93 + return true;
96 94 };
97 95 match session.token_exp {
98 96 Some(exp) => {
99 97 let now = chrono::Utc::now().timestamp();
100 - Ok(now >= exp - TOKEN_EXPIRY_BUFFER_SECS)
98 + now >= exp - TOKEN_EXPIRY_BUFFER_SECS
101 99 }
102 - None => Ok(false),
100 + None => false,
103 101 }
104 102 }
105 103
@@ -219,9 +217,9 @@ mod tests {
219 217 let client = SyncKitClient::new(test_config());
220 218 let (app_id, user_id) = test_ids();
221 219
222 - client.restore_session("fake-token", user_id, app_id).unwrap();
220 + client.restore_session("fake-token", user_id, app_id);
223 221
224 - let info = client.session_info().unwrap().expect("session should exist");
222 + let info = client.session_info().expect("session should exist");
225 223 assert_eq!(*info.token, "fake-token");
226 224 assert_eq!(info.user_id, user_id);
227 225 assert_eq!(info.app_id, app_id);
@@ -232,10 +230,10 @@ mod tests {
232 230 let client = SyncKitClient::new(test_config());
233 231 let (app_id, user_id) = test_ids();
234 232
235 - client.restore_session("first-token", user_id, app_id).unwrap();
236 - client.restore_session("second-token", user_id, app_id).unwrap();
233 + client.restore_session("first-token", user_id, app_id);
234 + client.restore_session("second-token", user_id, app_id);
237 235
238 - let info = client.session_info().unwrap().unwrap();
236 + let info = client.session_info().unwrap();
239 237 assert_eq!(*info.token, "second-token");
240 238 }
241 239
@@ -285,7 +283,7 @@ mod tests {
285 283 #[test]
286 284 fn is_token_expired_true_without_session() {
287 285 let client = SyncKitClient::new(test_config());
288 - assert!(client.is_token_expired().unwrap());
286 + assert!(client.is_token_expired());
289 287 }
290 288
291 289 #[test]
@@ -293,8 +291,8 @@ mod tests {
293 291 let client = SyncKitClient::new(test_config());
294 292 let (app_id, user_id) = test_ids();
295 293 let token = fake_jwt(Utc::now().timestamp() - 3600);
296 - client.restore_session(&token, user_id, app_id).unwrap();
297 - assert!(client.is_token_expired().unwrap());
294 + client.restore_session(&token, user_id, app_id);
295 + assert!(client.is_token_expired());
298 296 }
299 297
300 298 #[test]
@@ -302,8 +300,8 @@ mod tests {
302 300 let client = SyncKitClient::new(test_config());
303 301 let (app_id, user_id) = test_ids();
304 302 let token = fake_jwt(Utc::now().timestamp() + 3600);
305 - client.restore_session(&token, user_id, app_id).unwrap();
306 - assert!(!client.is_token_expired().unwrap());
303 + client.restore_session(&token, user_id, app_id);
304 + assert!(!client.is_token_expired());
307 305 }
308 306
309 307 // ── require_token with expiry ──
@@ -313,7 +311,7 @@ mod tests {
313 311 let client = SyncKitClient::new(test_config());
314 312 let (app_id, user_id) = test_ids();
315 313 let token = fake_jwt(Utc::now().timestamp() - 3600);
316 - client.restore_session(&token, user_id, app_id).unwrap();
314 + client.restore_session(&token, user_id, app_id);
317 315
318 316 let err = client.require_token().unwrap_err();
319 317 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -324,7 +322,7 @@ mod tests {
324 322 let client = SyncKitClient::new(test_config());
325 323 let (app_id, user_id) = test_ids();
326 324 let token = fake_jwt(Utc::now().timestamp() + 3600);
327 - client.restore_session(&token, user_id, app_id).unwrap();
325 + client.restore_session(&token, user_id, app_id);
328 326
329 327 assert!(client.require_token().is_ok());
330 328 }
@@ -335,16 +333,16 @@ mod tests {
335 333 fn clear_session_clears_master_key() {
336 334 let client = SyncKitClient::new(test_config());
337 335 let (app_id, user_id) = test_ids();
338 - client.restore_session("token", user_id, app_id).unwrap();
339 - client.set_master_key_raw([42u8; 32]).unwrap();
336 + client.restore_session("token", user_id, app_id);
337 + client.set_master_key_raw([42u8; 32]);
340 338
341 - assert!(client.session_info().unwrap().is_some());
342 - assert!(client.has_master_key().unwrap());
339 + assert!(client.session_info().is_some());
340 + assert!(client.has_master_key());
343 341
344 - client.clear_session().unwrap();
342 + client.clear_session();
345 343
346 - assert!(client.session_info().unwrap().is_none());
347 - assert!(!client.has_master_key().unwrap());
344 + assert!(client.session_info().is_none());
345 + assert!(!client.has_master_key());
348 346 }
349 347
350 348 // ── OAuth types ──
@@ -188,7 +188,7 @@ mod tests {
188 188 fn key_url_and_token_builds_correct_url() {
189 189 let client = SyncKitClient::new(test_config());
190 190 let (app_id, user_id) = test_ids();
191 - client.restore_session("bearer-token", user_id, app_id).unwrap();
191 + client.restore_session("bearer-token", user_id, app_id);
192 192
193 193 let (url, token) = client.key_url_and_token().unwrap();
194 194 assert_eq!(url, "https://example.com/api/sync/keys");
@@ -87,6 +87,20 @@ impl SyncKitClient {
87 87 }
88 88 }
89 89
90 + /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`].
91 + ///
92 + /// Used by `pull_rich()` to produce conflict-detection-ready results.
93 + pub(super) fn decrypt_change_to_pulled(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<crate::types::PulledChange> {
94 + let device_id = entry.device_id;
95 + let seq = entry.seq;
96 + let decrypted = Self::decrypt_change_with_key(entry, master_key)?;
97 + Ok(crate::types::PulledChange {
98 + entry: decrypted,
99 + device_id,
100 + seq,
101 + })
102 + }
103 +
90 104 /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
91 105 pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
92 106 let decrypted_data = match entry.data {
@@ -505,7 +519,7 @@ mod tests {
505 519 fn encrypt_change_preserves_all_metadata() {
506 520 let client = SyncKitClient::new(test_config());
507 521 let key = crypto::generate_master_key();
508 - client.set_master_key_raw(key).unwrap();
522 + client.set_master_key_raw(key);
509 523
510 524 let ts = Utc::now();
511 525 let entry = ChangeEntry {
@@ -529,7 +543,7 @@ mod tests {
529 543 fn multiple_entries_encrypt_decrypt_roundtrip() {
530 544 let client = SyncKitClient::new(test_config());
531 545 let key = crypto::generate_master_key();
532 - client.set_master_key_raw(key).unwrap();
546 + client.set_master_key_raw(key);
533 547
534 548 let entries = [
535 549 ChangeEntry {
@@ -589,7 +603,7 @@ mod tests {
589 603 fn encrypt_decrypt_roundtrip_unicode_table() {
590 604 let client = SyncKitClient::new(test_config());
591 605 let key = crypto::generate_master_key();
592 - client.set_master_key_raw(key).unwrap();
606 + client.set_master_key_raw(key);
593 607
594 608 let entry = ChangeEntry {
595 609 table: "\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30FC}\u{30D6}\u{30EB}".into(),
@@ -620,7 +634,7 @@ mod tests {
620 634 fn encrypt_decrypt_roundtrip_empty_row_id() {
621 635 let client = SyncKitClient::new(test_config());
622 636 let key = crypto::generate_master_key();
623 - client.set_master_key_raw(key).unwrap();
637 + client.set_master_key_raw(key);
624 638
625 639 let entry = ChangeEntry {
626 640 table: "t".into(),
M src/client/mod.rs +24 -20
@@ -53,8 +53,11 @@ mod auth;
53 53 mod blob;
54 54 mod encryption;
55 55 pub(crate) mod helpers;
56 + mod subscribe;
56 57 mod sync;
57 58
59 + pub use subscribe::SyncNotifyStream;
60 +
58 61 use parking_lot::RwLock;
59 62 use reqwest::Client;
60 63 use std::sync::Arc;
@@ -92,6 +95,7 @@ struct Endpoints {
92 95 devices: String,
93 96 push: String,
94 97 pull: String,
98 + subscribe: String,
95 99 status: String,
96 100 keys: String,
97 101 blobs_upload: String,
@@ -107,6 +111,7 @@ impl Endpoints {
107 111 devices: format!("{base}/api/sync/devices"),
108 112 push: format!("{base}/api/sync/push"),
109 113 pull: format!("{base}/api/sync/pull"),
114 + subscribe: format!("{base}/api/sync/subscribe"),
110 115 status: format!("{base}/api/sync/status"),
111 116 keys: format!("{base}/api/sync/keys"),
112 117 blobs_upload: format!("{base}/api/sync/blobs/upload"),
@@ -184,25 +189,24 @@ impl SyncKitClient {
184 189 }
185 190
186 191 /// Returns whether the master encryption key is loaded and ready.
187 - pub fn has_master_key(&self) -> Result<bool> {
188 - Ok(self.master_key.read().is_some())
192 + pub fn has_master_key(&self) -> bool {
193 + self.master_key.read().is_some()
189 194 }
190 195
191 196 /// Returns the current session info, if authenticated.
192 - pub fn session_info(&self) -> Result<Option<SessionInfo>> {
197 + pub fn session_info(&self) -> Option<SessionInfo> {
193 198 let guard = self.session.read();
194 - Ok(guard.as_ref().map(|s| SessionInfo {
199 + guard.as_ref().map(|s| SessionInfo {
195 200 token: Arc::clone(&s.token),
196 201 user_id: s.user_id,
197 202 app_id: s.app_id,
198 - }))
203 + })
199 204 }
200 205
201 206 /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
202 207 #[doc(hidden)]
203 - pub fn set_master_key_raw(&self, key: [u8; 32]) -> Result<()> {
208 + pub fn set_master_key_raw(&self, key: [u8; 32]) {
204 209 *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
205 - Ok(())
206 210 }
207 211
208 212 // ── Internal helpers ──
@@ -305,13 +309,13 @@ mod tests {
305 309 #[test]
306 310 fn new_client_starts_unauthenticated() {
307 311 let client = SyncKitClient::new(test_config());
308 - assert!(client.session_info().unwrap().is_none());
312 + assert!(client.session_info().is_none());
309 313 }
310 314
311 315 #[test]
312 316 fn new_client_has_no_master_key() {
313 317 let client = SyncKitClient::new(test_config());
314 - assert!(!client.has_master_key().unwrap());
318 + assert!(!client.has_master_key());
315 319 }
316 320
317 321 #[test]
@@ -352,7 +356,7 @@ mod tests {
352 356 fn require_token_succeeds_with_session() {
353 357 let client = SyncKitClient::new(test_config());
354 358 let (app_id, user_id) = test_ids();
355 - client.restore_session("my-token", user_id, app_id).unwrap();
359 + client.restore_session("my-token", user_id, app_id);
356 360
357 361 let token = client.require_token().unwrap();
358 362 assert_eq!(*token, "my-token");
@@ -371,7 +375,7 @@ mod tests {
371 375 fn require_session_ids_returns_correct_ids() {
372 376 let client = SyncKitClient::new(test_config());
373 377 let (app_id, user_id) = test_ids();
374 - client.restore_session("token", user_id, app_id).unwrap();
378 + client.restore_session("token", user_id, app_id);
375 379
376 380 let (returned_app, returned_user) = client.require_session_ids().unwrap();
377 381 assert_eq!(returned_app, app_id);
@@ -402,14 +406,14 @@ mod tests {
402 406 #[test]
403 407 fn has_master_key_false_initially() {
404 408 let client = SyncKitClient::new(test_config());
405 - assert!(!client.has_master_key().unwrap());
409 + assert!(!client.has_master_key());
406 410 }
407 411
408 412 #[test]
409 413 fn has_master_key_true_after_set() {
410 414 let client = SyncKitClient::new(test_config());
411 415 *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32]));
412 - assert!(client.has_master_key().unwrap());
416 + assert!(client.has_master_key());
413 417 }
414 418
415 419 // ── set_master_key_raw ──
@@ -417,12 +421,12 @@ mod tests {
417 421 #[test]
418 422 fn set_master_key_raw_makes_key_available() {
419 423 let client = SyncKitClient::new(test_config());
420 - assert!(!client.has_master_key().unwrap());
424 + assert!(!client.has_master_key());
421 425
422 426 let key = [99u8; 32];
423 - client.set_master_key_raw(key).unwrap();
427 + client.set_master_key_raw(key);
424 428
425 - assert!(client.has_master_key().unwrap());
429 + assert!(client.has_master_key());
426 430 assert_eq!(*client.require_master_key().unwrap(), key);
427 431 }
428 432
@@ -432,10 +436,10 @@ mod tests {
432 436 let key1 = [1u8; 32];
433 437 let key2 = [2u8; 32];
434 438
435 - client.set_master_key_raw(key1).unwrap();
439 + client.set_master_key_raw(key1);
436 440 assert_eq!(*client.require_master_key().unwrap(), key1);
437 441
438 - client.set_master_key_raw(key2).unwrap();
442 + client.set_master_key_raw(key2);
439 443 assert_eq!(*client.require_master_key().unwrap(), key2);
440 444 }
441 445
@@ -448,8 +452,8 @@ mod tests {
448 452 .build()
449 453 .unwrap();
450 454 let client = SyncKitClient::with_http_client(test_config(), http);
451 - assert!(client.session_info().unwrap().is_none());
452 - assert!(!client.has_master_key().unwrap());
455 + assert!(client.session_info().is_none());
456 + assert!(!client.has_master_key());
453 457 }
454 458
455 459 // ── Send + Sync assertions ──
@@ -0,0 +1,148 @@
1 + //! SSE push notification stream for real-time sync notifications.
2 + //!
3 + //! The server sends zero-data `event: changed` events over SSE whenever
4 + //! another device pushes changes. The client should pull on each event.
5 + //! No auto-reconnect — consumer apps handle reconnect in their scheduler.
6 +
7 + use crate::error::{Result, SyncKitError};
8 +
9 + use super::SyncKitClient;
10 +
11 + /// A stream of SSE "changed" notifications from the SyncKit server.
12 + ///
13 + /// Created by [`SyncKitClient::subscribe`]. Wraps a raw HTTP response byte
14 + /// stream and parses SSE events line by line. Yields `Some(())` for each
15 + /// `event: changed`, `None` when the stream ends or encounters an error.
16 + pub struct SyncNotifyStream {
17 + buffer: String,
18 + response: reqwest::Response,
19 + }
20 +
21 + impl SyncNotifyStream {
22 + pub(crate) fn new(response: reqwest::Response) -> Self {
23 + Self {
24 + buffer: String::new(),
25 + response,
26 + }
27 + }
28 +
29 + /// Wait for the next "changed" notification.
30 + ///
31 + /// Returns `Some(())` when the server signals new changes are available.
32 + /// Returns `None` when the stream ends (server closed, network error).
33 + /// Keepalive comments (lines starting with `:`) are silently ignored.
34 + pub async fn next_change(&mut self) -> Option<()> {
35 + loop {
36 + // Try to extract a complete SSE block from the buffer
37 + if let Some(pos) = self.buffer.find("\n\n") {
38 + let block = self.buffer[..pos].to_string();
39 + self.buffer = self.buffer[pos + 2..].to_string();
40 +
41 + if parse_sse_block_is_changed(&block) {
42 + return Some(());
43 + }
44 + // Not a "changed" event — skip and try next block
45 + continue;
46 + }
47 +
48 + // Need more data from the stream
49 + match self.response.chunk().await {
50 + Ok(Some(chunk)) => {
51 + let text = String::from_utf8_lossy(&chunk);
52 + self.buffer.push_str(&text);
53 + }
54 + Ok(None) | Err(_) => return None,
55 + }
56 + }
57 + }
58 + }
59 +
60 + /// Parse an SSE block and return `true` if it contains `event: changed`.
61 + ///
62 + /// An SSE block is one or more lines separated by `\n`, terminated by `\n\n`.
63 + /// Lines starting with `:` are comments (keepalive). The `event:` field
64 + /// specifies the event type, `data:` the payload (ignored here).
65 + fn parse_sse_block_is_changed(block: &str) -> bool {
66 + for line in block.lines() {
67 + let trimmed = line.trim();
68 + // Skip comments
69 + if trimmed.starts_with(':') {
70 + continue;
71 + }
72 + if let Some(value) = trimmed.strip_prefix("event:") {
73 + if value.trim() == "changed" {
74 + return true;
75 + }
76 + }
77 + }
78 + false
79 + }
80 +
81 + impl SyncKitClient {
82 + /// Open an SSE connection for real-time push notifications.
83 + ///
84 + /// Returns a [`SyncNotifyStream`] that yields `Some(())` each time the
85 + /// server signals new changes are available. The caller should pull after
86 + /// each notification to get the actual changes.
87 + ///
88 + /// The stream does not auto-reconnect. If the connection drops,
89 + /// `next_change()` returns `None` and the caller should reconnect
90 + /// (typically after a short delay).
91 + #[tracing::instrument(skip(self))]
92 + pub async fn subscribe(&self) -> Result<SyncNotifyStream> {
93 + let token = self.require_token()?;
94 + let (app_id, _user_id) = self.require_session_ids()?;
95 + let url = format!("{}?app_id={}", self.endpoints.subscribe, app_id);
96 +
97 + let resp = self
98 + .http
99 + .get(&url)
100 + .bearer_auth(&*token)
101 + .send()
102 + .await
103 + .map_err(SyncKitError::Http)?;
104 +
105 + let status = resp.status().as_u16();
106 + if status >= 400 {
107 + let message = resp.text().await.unwrap_or_default();
108 + return Err(SyncKitError::Server { status, message });
109 + }
110 +
111 + Ok(SyncNotifyStream::new(resp))
112 + }
113 + }
114 +
115 + #[cfg(test)]
116 + mod tests {
117 + use super::*;
118 +
119 + #[test]
120 + fn parse_changed_event() {
121 + let block = "event: changed\ndata: {}";
122 + assert!(parse_sse_block_is_changed(block));
123 + }
124 +
125 + #[test]
126 + fn parse_ignores_keepalive_comments() {
127 + let block = ": keepalive";
128 + assert!(!parse_sse_block_is_changed(block));
129 + }
130 +
131 + #[test]
132 + fn parse_handles_malformed_lines() {
133 + let block = "garbled nonsense";
134 + assert!(!parse_sse_block_is_changed(block));
135 +
136 + let block2 = "";
137 + assert!(!parse_sse_block_is_changed(block2));
138 +
139 + let block3 = "event:other\ndata: test";
140 + assert!(!parse_sse_block_is_changed(block3));
141 + }
142 +
143 + #[test]
144 + fn parse_changed_with_extra_whitespace() {
145 + let block = "event: changed \ndata: {}";
146 + assert!(parse_sse_block_is_changed(block));
147 + }
148 + }
@@ -154,6 +154,152 @@ impl SyncKitClient {
154 154 Ok((changes, pull_resp.cursor, pull_resp.has_more))
155 155 }
156 156
157 + /// Pull changes from the server with optional table and timestamp filters.
158 + /// Decrypts `data` fields automatically.
159 + /// Returns (changes, new_cursor, has_more).
160 + ///
161 + /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default.
162 + #[instrument(skip(self, filter))]
163 + pub async fn pull_filtered(
164 + &self,
165 + device_id: Uuid,
166 + cursor: i64,
167 + filter: PullFilter,
168 + ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
169 + let token = self.require_token()?;
170 +
171 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
172 + device_id,
173 + cursor,
174 + tables: filter.tables,
175 + since: filter.since,
176 + })?);
177 +
178 + let resp = self
179 + .retry_request(|| {
180 + let req = self
181 + .http
182 + .post(&self.endpoints.pull)
183 + .bearer_auth(&token)
184 + .header("content-type", "application/json")
185 + .body(body.clone());
186 + async move { check_response(req.send().await?).await }
187 + })
188 + .await?;
189 +
190 + let pull_resp: PullResponse = resp.json().await?;
191 +
192 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
193 + let key_holder = if has_data {
194 + self.require_master_key()?
195 + } else {
196 + crypto::ZeroizeOnDrop([0u8; 32])
197 + };
198 + let master_key: &[u8; 32] = &key_holder;
199 + let changes = pull_resp
200 + .changes
201 + .into_iter()
202 + .map(|c| Self::decrypt_change_with_key(c, master_key))
203 + .collect::<Result<Vec<_>>>()?;
204 +
205 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
206 + }
207 +
208 + /// Pull changes from the server, preserving `device_id` and `seq` metadata.
209 + ///
210 + /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns
211 + /// [`PulledChange`] wrappers that retain server metadata needed for conflict
212 + /// detection. Returns (changes, new_cursor, has_more).
213 + #[instrument(skip(self))]
214 + pub async fn pull_rich(
215 + &self,
216 + device_id: Uuid,
217 + cursor: i64,
218 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
219 + let token = self.require_token()?;
220 +
221 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
222 +
223 + let resp = self
224 + .retry_request(|| {
225 + let req = self
226 + .http
227 + .post(&self.endpoints.pull)
228 + .bearer_auth(&token)
229 + .header("content-type", "application/json")
230 + .body(body.clone());
231 + async move { check_response(req.send().await?).await }
232 + })
233 + .await?;
234 +
235 + let pull_resp: PullResponse = resp.json().await?;
236 +
237 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
238 + let key_holder = if has_data {
239 + self.require_master_key()?
240 + } else {
241 + crypto::ZeroizeOnDrop([0u8; 32])
242 + };
243 + let master_key: &[u8; 32] = &key_holder;
244 + let changes = pull_resp
245 + .changes
246 + .into_iter()
247 + .map(|c| Self::decrypt_change_to_pulled(c, master_key))
248 + .collect::<Result<Vec<_>>>()?;
249 +
250 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
251 + }
252 +
253 + /// Pull changes with filters, preserving `device_id` and `seq` metadata.
254 + ///
255 + /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp
256 + /// filtering support. Returns (changes, new_cursor, has_more).
257 + #[instrument(skip(self, filter))]
258 + pub async fn pull_filtered_rich(
259 + &self,
260 + device_id: Uuid,
261 + cursor: i64,
262 + filter: PullFilter,
263 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
264 + let token = self.require_token()?;
265 +
266 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
267 + device_id,
268 + cursor,
269 + tables: filter.tables,
270 + since: filter.since,
271 + })?);
272 +
273 + let resp = self
274 + .retry_request(|| {
275 + let req = self
276 + .http
277 + .post(&self.endpoints.pull)
278 + .bearer_auth(&token)
279 + .header("content-type", "application/json")
280 + .body(body.clone());
281 + async move { check_response(req.send().await?).await }
282 + })
283 + .await?;
284 +
285 + let pull_resp: PullResponse = resp.json().await?;
286 +
287 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
288 + let key_holder = if has_data {
289 + self.require_master_key()?
290 + } else {
291 + crypto::ZeroizeOnDrop([0u8; 32])
292 + };
293 + let master_key: &[u8; 32] = &key_holder;
294 + let changes = pull_resp
295 + .changes
296 + .into_iter()
297 + .map(|c| Self::decrypt_change_to_pulled(c, master_key))
298 + .collect::<Result<Vec<_>>>()?;
299 +
300 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
301 + }
302 +
157 303 /// Get sync status (total changes, latest cursor).
158 304 #[instrument(skip(self))]
159 305 pub async fn status(&self) -> Result<SyncStatus> {
@@ -0,0 +1,630 @@
1 + //! Client-side conflict detection and resolution for SyncKit.
2 + //!
3 + //! SyncKit uses E2E encryption — the server never sees row contents and cannot
4 + //! merge or compare data. All conflict resolution must happen client-side after
5 + //! decryption.
6 + //!
7 + //! This module provides:
8 + //! - [`detect_conflicts`]: pure function that splits pulled changes into clean
9 + //! (non-conflicting) and conflicting sets
10 + //! - [`resolve_lww`]: last-write-wins resolution by `client_timestamp`
11 + //! - [`resolve_field_merge`]: 3-way JSON object merge using a base version
12 + //! - [`ConflictResolver`]: trait for custom resolution strategies
13 +
14 + use std::collections::HashMap;
15 +
16 + use chrono::{DateTime, Utc};
17 + use uuid::Uuid;
18 +
19 + use crate::types::{ChangeEntry, ChangeOp, PulledChange};
20 +
21 + /// A remote change that conflicts with a local pending change.
22 + #[derive(Debug, Clone)]
23 + pub struct ConflictPair {
24 + /// The remote change from pull.
25 + pub remote: PulledChange,
26 + /// The local pending change that conflicts.
27 + pub local: ChangeEntry,
28 + }
29 +
30 + /// How a conflict should be resolved.
31 + #[derive(Debug, Clone)]
32 + pub enum Resolution {
33 + /// Keep the local version; skip the remote change.
34 + /// The local version will push on the next sync cycle.
35 + KeepLocal,
36 + /// Apply the remote change, discarding the local version.
37 + KeepRemote,
38 + /// Apply a merged result that combines both changes.
39 + Merged(serde_json::Value),
40 + /// Skip both changes (neither apply nor push).
41 + Skip,
42 + }
43 +
44 + /// Trait for custom conflict resolution strategies.
45 + ///
46 + /// Implement this to plug in app-specific merge logic. The `base` parameter
47 + /// is `Some` only if the app provides a base-store adapter.
48 + pub trait ConflictResolver: Send + Sync {
49 + fn resolve(
50 + &self,
51 + local: &ChangeEntry,
52 + remote: &PulledChange,
53 + base: Option<&serde_json::Value>,
54 + ) -> Resolution;
55 + }
56 +
57 + /// Split pulled changes into non-conflicting and conflicting sets.
58 + ///
59 + /// A conflict exists when a remote change and a local pending change both
60 + /// modify the same `(table, row_id)` from different devices. Changes from
61 + /// our own device (echoes) are never treated as conflicts.
62 + ///
63 + /// Returns `(clean, conflicts)` where `clean` changes can be applied directly.
64 + pub fn detect_conflicts(
65 + remote: Vec<PulledChange>,
66 + local_pending: &[ChangeEntry],
67 + our_device_id: Uuid,
68 + ) -> (Vec<PulledChange>, Vec<ConflictPair>) {
69 + // Build lookup: (table, row_id) -> last local pending entry
70 + let mut local_map: HashMap<(&str, &str), &ChangeEntry> = HashMap::new();
71 + for entry in local_pending {
72 + local_map.insert((&entry.table, &entry.row_id), entry);
73 + }
74 +
75 + let mut clean = Vec::new();
76 + let mut conflicts = Vec::new();
77 +
78 + for pulled in remote {
79 + // Our own echo — never a conflict
80 + if pulled.device_id == our_device_id {
81 + clean.push(pulled);
82 + continue;
83 + }
84 +
85 + let key = (pulled.entry.table.as_str(), pulled.entry.row_id.as_str());
86 + if let Some(&local_entry) = local_map.get(&key) {
87 + conflicts.push(ConflictPair {
88 + remote: pulled,
89 + local: local_entry.clone(),
90 + });
91 + } else {
92 + clean.push(pulled);
93 + }
94 + }
95 +
96 + (clean, conflicts)
97 + }
98 +
99 + /// Last-write-wins resolution by `client_timestamp`.
100 + ///
101 + /// - DELETE vs non-DELETE: delete wins regardless of timestamp.
102 + /// - Both INSERT/UPDATE: newer timestamp wins. Ties go to local.
103 + pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution {
104 + // DELETE wins over non-DELETE
105 + if local.op == ChangeOp::Delete || remote.entry.op == ChangeOp::Delete {
106 + return if local.op == ChangeOp::Delete {
107 + Resolution::KeepLocal
108 + } else {
109 + Resolution::KeepRemote
110 + };
111 + }
112 +
113 + // Both are INSERT/UPDATE: newer timestamp wins, ties go to local
114 + if local.timestamp >= remote.entry.timestamp {
115 + Resolution::KeepLocal
116 + } else {
117 + Resolution::KeepRemote
118 + }
119 + }
120 +
121 + /// 3-way field-level merge for JSON objects.
122 + ///
123 + /// Compares `local` and `remote` against `base` to determine which fields
124 + /// each side changed, then merges non-overlapping changes. For overlapping
125 + /// fields, the newer timestamp wins.
126 + ///
127 + /// Returns `Resolution::KeepRemote` if any input is not a JSON object.
128 + pub fn resolve_field_merge(
129 + local: &serde_json::Value,
130 + remote: &serde_json::Value,
131 + base: &serde_json::Value,
132 + local_ts: DateTime<Utc>,
133 + remote_ts: DateTime<Utc>,
134 + ) -> Resolution {
135 + let (Some(local_obj), Some(remote_obj), Some(base_obj)) = (
136 + local.as_object(),
137 + remote.as_object(),
138 + base.as_object(),
139 + ) else {
140 + return Resolution::KeepRemote;
141 + };
142 +
143 + // Compute diffs: keys where local/remote differ from base
144 + let mut local_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new();
145 + let mut remote_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new();
146 +
147 + // Check keys in base for changes or deletions
148 + for key in base_obj.keys() {
149 + let base_val = &base_obj[key];
150 +
151 + match local_obj.get(key) {
152 + Some(local_val) if local_val != base_val => {
153 + local_changed.insert(key, Some(local_val));
154 + }
155 + None => {
156 + // Key deleted on local side
157 + local_changed.insert(key, None);
158 + }
159 + _ => {}
160 + }
161 +
162 + match remote_obj.get(key) {
163 + Some(remote_val) if remote_val != base_val => {
164 + remote_changed.insert(key, Some(remote_val));
165 + }
166 + None => {
167 + // Key deleted on remote side
168 + remote_changed.insert(key, None);
169 + }
170 + _ => {}
171 + }
172 + }
173 +
174 + // Check for new keys added by local (not in base)
175 + for (key, val) in local_obj {
176 + if !base_obj.contains_key(key) {
177 + local_changed.insert(key, Some(val));
178 + }
179 + }
180 +
181 + // Check for new keys added by remote (not in base)
182 + for (key, val) in remote_obj {
183 + if !base_obj.contains_key(key) {
184 + remote_changed.insert(key, Some(val));
185 + }
186 + }
187 +
188 + // Build merged result starting from base
189 + let mut result = base_obj.clone();
190 +
191 + // Apply local changes
192 + for (key, val) in &local_changed {
193 + match val {
194 + Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
195 + None => { result.remove(*key); }
196 + }
197 + }
198 +
199 + // Apply remote changes (non-overlapping only, or newer timestamp wins)
200 + for (key, val) in &remote_changed {
201 + if local_changed.contains_key(key) {
202 + // Overlapping: newer timestamp wins, ties go to local
203 + if remote_ts > local_ts {
204 + match val {
205 + Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
206 + None => { result.remove(*key); }
207 + }
208 + }
209 + // else: local already applied above
210 + } else {
211 + // Non-overlapping: apply remote
212 + match val {
213 + Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
214 + None => { result.remove(*key); }
215 + }
216 + }
217 + }
218 +
219 + Resolution::Merged(serde_json::Value::Object(result))
220 + }
221 +
222 + #[cfg(test)]
223 + mod tests {
224 + use super::*;
225 + use serde_json::json;
226 +
227 + fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry {
228 + ChangeEntry {
229 + table: table.to_string(),
230 + op,
231 + row_id: row_id.to_string(),
232 + timestamp: ts,
233 + data: Some(json!({"value": "test"})),
234 + }
235 + }
236 +
237 + fn make_pulled(
238 + table: &str,
239 + row_id: &str,
240 + op: ChangeOp,
241 + ts: DateTime<Utc>,
242 + device_id: Uuid,
243 + seq: i64,
244 + ) -> PulledChange {
245 + PulledChange {
246 + entry: make_entry(table, row_id, op, ts),
247 + device_id,
248 + seq,
249 + }
250 + }
251 +
252 + // ── detect_conflicts ──
253 +
254 + #[test]
255 + fn no_conflicts_when_different_rows() {
256 + let our_device = Uuid::new_v4();
257 + let other_device = Uuid::new_v4();
258 + let now = Utc::now();
259 +
260 + let remote = vec![
261 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
262 + ];
263 + let local = vec![
264 + make_entry("tasks", "r2", ChangeOp::Update, now),
265 + ];
266 +
267 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
268 + assert_eq!(clean.len(), 1);
269 + assert!(conflicts.is_empty());
270 + }
271 +
272 + #[test]
273 + fn conflict_detected_same_row_different_device() {
274 + let our_device = Uuid::new_v4();
275 + let other_device = Uuid::new_v4();
276 + let now = Utc::now();
277 +
278 + let remote = vec![
279 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
280 + ];
281 + let local = vec![
282 + make_entry("tasks", "r1", ChangeOp::Update, now),
283 + ];
284 +
285 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
286 + assert!(clean.is_empty());
287 + assert_eq!(conflicts.len(), 1);
288 + assert_eq!(conflicts[0].remote.entry.row_id, "r1");
289 + assert_eq!(conflicts[0].local.row_id, "r1");
290 + }
291 +
292 + #[test]
293 + fn own_echo_not_treated_as_conflict() {
294 + let our_device = Uuid::new_v4();
295 + let now = Utc::now();
296 +
297 + let remote = vec![
298 + make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1),
299 + ];
300 + let local = vec![
301 + make_entry("tasks", "r1", ChangeOp::Update, now),
302 + ];
303 +
304 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
305 + assert_eq!(clean.len(), 1);
306 + assert!(conflicts.is_empty());
307 + }
308 +
309 + #[test]
310 + fn different_tables_same_row_id_no_conflict() {
311 + let our_device = Uuid::new_v4();
312 + let other_device = Uuid::new_v4();
313 + let now = Utc::now();
314 +
315 + let remote = vec![
316 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
317 + ];
318 + let local = vec![
319 + make_entry("events", "r1", ChangeOp::Update, now),
320 + ];
321 +
322 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
323 + assert_eq!(clean.len(), 1);
324 + assert!(conflicts.is_empty());
325 + }
326 +
327 + #[test]
328 + fn detect_conflicts_correct_split() {
329 + let our_device = Uuid::new_v4();
330 + let other_device = Uuid::new_v4();
331 + let now = Utc::now();
332 +
333 + let remote = vec![
334 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
335 + make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2),
336 + make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3),
337 + ];
338 + let local = vec![
339 + make_entry("tasks", "r1", ChangeOp::Update, now),
340 + // r2 not in local → clean
341 + // r3 not in local → clean
342 + ];
343 +
344 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
345 + assert_eq!(clean.len(), 2);
346 + assert_eq!(conflicts.len(), 1);
347 + assert_eq!(conflicts[0].remote.entry.row_id, "r1");
348 + }
349 +
350 + #[test]
351 + fn empty_remote_produces_no_conflicts() {
352 + let our_device = Uuid::new_v4();
353 + let now = Utc::now();
354 +
355 + let remote = vec![];
356 + let local = vec![
357 + make_entry("tasks", "r1", ChangeOp::Update, now),
358 + ];
359 +
360 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
361 + assert!(clean.is_empty());
362 + assert!(conflicts.is_empty());
363 + }
364 +
365 + #[test]
366 + fn empty_local_produces_no_conflicts() {
367 + let our_device = Uuid::new_v4();
368 + let other_device = Uuid::new_v4();
369 + let now = Utc::now();
370 +
371 + let remote = vec![
372 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
373 + ];
374 + let local: Vec<ChangeEntry> = vec![];
375 +
376 + let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
377 + assert_eq!(clean.len(), 1);
378 + assert!(conflicts.is_empty());
379 + }
380 +
381 + // ── resolve_lww ──
382 +
383 + #[test]
384 + fn lww_picks_newer_timestamp() {
385 + let other_device = Uuid::new_v4();
386 + let old = Utc::now() - chrono::Duration::seconds(60);
387 + let new = Utc::now();
388 +
389 + let local = make_entry("tasks", "r1", ChangeOp::Update, old);
390 + let remote = make_pulled("tasks", "r1", ChangeOp::Update, new, other_device, 1);
391 +
392 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
393 + }
394 +
395 + #[test]
396 + fn lww_local_wins_when_newer() {
397 + let other_device = Uuid::new_v4();
398 + let old = Utc::now() - chrono::Duration::seconds(60);
399 + let new = Utc::now();
400 +
401 + let local = make_entry("tasks", "r1", ChangeOp::Update, new);
402 + let remote = make_pulled("tasks", "r1", ChangeOp::Update, old, other_device, 1);
403 +
404 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
405 + }
406 +
407 + #[test]
408 + fn lww_equal_timestamps_local_wins() {
409 + let other_device = Uuid::new_v4();
410 + let now = Utc::now();
411 +
412 + let local = make_entry("tasks", "r1", ChangeOp::Update, now);
413 + let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1);
414 +
415 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
416 + }
417 +
418 + #[test]
419 + fn lww_local_delete_wins_over_update() {
420 + let other_device = Uuid::new_v4();
421 + let now = Utc::now();
422 +
423 + let local = make_entry("tasks", "r1", ChangeOp::Delete, now);
424 + let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1);
425 +
426 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
427 + }
428 +
429 + #[test]
430 + fn lww_remote_delete_wins_over_update() {
431 + let other_device = Uuid::new_v4();
432 + let now = Utc::now();
433 +
434 + let local = make_entry("tasks", "r1", ChangeOp::Update, now);
435 + let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1);
436 +
437 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
438 + }
439 +
440 + #[test]
441 + fn lww_both_delete_local_wins() {
442 + let other_device = Uuid::new_v4();
443 + let now = Utc::now();
444 +
445 + let local = make_entry("tasks", "r1", ChangeOp::Delete, now);
446 + let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1);
447 +
448 + assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
449 + }
450 +
451 + // ── resolve_field_merge ──
452 +
453 + #[test]
454 + fn field_merge_non_overlapping_changes() {
455 + let base = json!({"title": "old", "status": "pending", "priority": 1});
456 + let local = json!({"title": "new title", "status": "pending", "priority": 1});
457 + let remote = json!({"title": "old", "status": "done", "priority": 1});
458 + let now = Utc::now();
459 +
460 + let result = resolve_field_merge(&local, &remote, &base, now, now);
461 + match result {
462 + Resolution::Merged(v) => {
463 + assert_eq!(v["title"], "new title");
464 + assert_eq!(v["status"], "done");
465 + assert_eq!(v["priority"], 1);
466 + }
467 + _ => panic!("Expected Merged"),
468 + }
469 + }
470 +
471 + #[test]
472 + fn field_merge_overlapping_newer_wins() {
473 + let base = json!({"title": "old"});
474 + let local = json!({"title": "local title"});
475 + let remote = json!({"title": "remote title"});
476 + let old = Utc::now() - chrono::Duration::seconds(60);
477 + let new = Utc::now();
478 +
479 + // Remote is newer → remote wins the overlapping field
480 + let result = resolve_field_merge(&local, &remote, &base, old, new);
481 + match result {
482 + Resolution::Merged(v) => {
483 + assert_eq!(v["title"], "remote title");
484 + }
485 + _ => panic!("Expected Merged"),
486 + }
487 +
488 + // Local is newer → local wins the overlapping field
489 + let result = resolve_field_merge(&local, &remote, &base, new, old);
490 + match result {
491 + Resolution::Merged(v) => {
492 + assert_eq!(v["title"], "local title");
493 + }
494 + _ => panic!("Expected Merged"),
495 + }
496 + }
497 +
498 + #[test]
499 + fn field_merge_key_deleted_on_one_side() {
500 + let base = json!({"title": "old", "notes": "some notes"});
Lines truncated
@@ -60,6 +60,13 @@ pub enum SyncKitError {
60 60 Keychain(String),
61 61 }
62 62
63 + #[cfg(feature = "keychain")]
64 + impl From<keyring::Error> for SyncKitError {
65 + fn from(e: keyring::Error) -> Self {
66 + SyncKitError::Keychain(e.to_string())
67 + }
68 + }
69 +
63 70 /// Convenience alias.
64 71 pub type Result<T> = std::result::Result<T, SyncKitError>;
65 72
M src/keystore.rs +6 -12
@@ -36,13 +36,9 @@ fn user_key(user_id: Uuid) -> String {
36 36 /// Store the master key in the OS keychain.
37 37 #[cfg(feature = "keychain")]
38 38 pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> {
39 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
40 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
41 -
39 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
42 40 let encoded = B64.encode(master_key);
43 - entry
44 - .set_password(&encoded)
45 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
41 + entry.set_password(&encoded)?;
46 42
47 43 tracing::debug!("Master key stored in OS keychain");
48 44 Ok(())
@@ -52,8 +48,7 @@ pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<(
52 48 /// Returns None if no key is stored (not an error).
53 49 #[cfg(feature = "keychain")]
54 50 pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> {
55 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
56 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
51 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
57 52
58 53 match entry.get_password() {
59 54 Ok(encoded) => {
@@ -69,15 +64,14 @@ pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> {
69 64 Ok(Some(key))
70 65 }
71 66 Err(keyring::Error::NoEntry) => Ok(None),
72 - Err(e) => Err(SyncKitError::Keychain(e.to_string())),
67 + Err(e) => Err(e.into()),
73 68 }
74 69 }
75 70
76 71 /// Delete the master key from the OS keychain.
77 72 #[cfg(feature = "keychain")]
78 73 pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> {
79 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
80 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
74 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
81 75
82 76 match entry.delete_credential() {
83 77 Ok(()) => {
@@ -85,7 +79,7 @@ pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> {
85 79 Ok(())
86 80 }
87 81 Err(keyring::Error::NoEntry) => Ok(()), // Already gone
88 - Err(e) => Err(SyncKitError::Keychain(e.to_string())),
82 + Err(e) => Err(e.into()),
89 83 }
90 84 }
91 85
M src/lib.rs +6 -2
@@ -42,12 +42,16 @@
42 42 //! ```
43 43
44 44 pub mod client;
45 + pub mod conflict;
45 46 pub mod crypto;
46 47 pub mod error;
47 48 pub mod keystore;
48 49 pub mod types;
49 50
50 51 // Re-exports for convenience
51 - pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig};
52 + pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream};
53 + pub use conflict::{
54 + detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution,
55 + };
52 56 pub use error::{Result, SyncKitError};
53 - pub use types::{ChangeEntry, ChangeOp, Device, SyncStatus};
57 + pub use types::{ChangeEntry, ChangeOp, Device, PullFilter, PulledChange, SyncStatus};
M src/types.rs +83
@@ -160,6 +160,50 @@ pub(crate) struct PullChangeEntry {
160 160 pub data: Option<serde_json::Value>,
161 161 }
162 162
163 + // ── Filtered pull ──
164 +
165 + /// Optional filters for [`SyncKitClient::pull_filtered`].
166 + ///
167 + /// Both fields are optional and compose with AND. An empty/default filter
168 + /// is equivalent to an unfiltered pull.
169 + #[derive(Debug, Clone, Default, Serialize)]
170 + pub struct PullFilter {
171 + /// Only return entries for these table names.
172 + #[serde(skip_serializing_if = "Option::is_none")]
173 + pub tables: Option<Vec<String>>,
174 + /// Only return entries with `client_timestamp >= since`.
175 + #[serde(skip_serializing_if = "Option::is_none")]
176 + pub since: Option<DateTime<Utc>>,
177 + }
178 +
179 + #[derive(Serialize)]
180 + pub(crate) struct FilteredPullRequest {
181 + pub device_id: Uuid,
182 + pub cursor: i64,
183 + #[serde(skip_serializing_if = "Option::is_none")]
184 + pub tables: Option<Vec<String>>,
185 + #[serde(skip_serializing_if = "Option::is_none")]
186 + pub since: Option<DateTime<Utc>>,
187 + }
188 +
189 + // ── Pulled change (rich metadata) ──
190 +
191 + /// A change entry from pull with server metadata preserved.
192 + ///
193 + /// Wraps [`ChangeEntry`] with `device_id` and `seq` fields that are normally
194 + /// discarded during `pull()`. Used by [`SyncKitClient::pull_rich`] for
195 + /// conflict detection — the `device_id` identifies whether a change came from
196 + /// another device, and `seq` provides total server ordering.
197 + #[derive(Debug, Clone)]
198 + pub struct PulledChange {
199 + /// The decrypted change entry.
200 + pub entry: ChangeEntry,
201 + /// The device that originated this change.
202 + pub device_id: Uuid,
203 + /// Server sequence number (total ordering).
204 + pub seq: i64,
205 + }
206 +
163 207 // ── Keys ──
164 208
165 209 #[derive(Serialize)]
@@ -382,4 +426,43 @@ mod tests {
382 426 assert_eq!(format!("{:?}", ChangeOp::Update), "Update");
383 427 assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete");
384 428 }
429 +
430 + // ── PullFilter ──
431 +
432 + #[test]
433 + fn pull_filter_serialization_with_both_fields() {
434 + let filter = PullFilter {
435 + tables: Some(vec!["tasks".to_string(), "events".to_string()]),
436 + since: Some("2025-06-15T12:00:00Z".parse().unwrap()),
437 + };
438 + let json = serde_json::to_string(&filter).unwrap();
439 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
440 + assert_eq!(parsed["tables"].as_array().unwrap().len(), 2);
441 + assert!(parsed["since"].is_string());
442 + }
443 +
444 + #[test]
445 + fn pull_filter_serialization_with_none_fields() {
446 + let filter = PullFilter::default();
447 + let json = serde_json::to_string(&filter).unwrap();
448 + // None fields should be omitted entirely
449 + assert!(!json.contains("tables"));
450 + assert!(!json.contains("since"));
451 + assert_eq!(json, "{}");
452 + }
453 +
454 + #[test]
455 + fn filtered_pull_request_includes_filter_fields() {
456 + let req = FilteredPullRequest {
457 + device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
458 + cursor: 42,
459 + tables: Some(vec!["tasks".to_string()]),
460 + since: Some("2025-01-01T00:00:00Z".parse().unwrap()),
461 + };
462 + let json = serde_json::to_string(&req).unwrap();
463 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
464 + assert_eq!(parsed["cursor"], 42);
465 + assert_eq!(parsed["tables"].as_array().unwrap().len(), 1);
466 + assert!(parsed["since"].is_string());
467 + }
385 468 }
@@ -54,8 +54,7 @@ fn authed_client(server: &MockServer) -> SyncKitClient {
54 54 let client = client_for(server);
55 55 let (user_id, app_id) = test_ids();
56 56 client
57 - .restore_session(&fresh_token(), user_id, app_id)
58 - .unwrap();
57 + .restore_session(&fresh_token(), user_id, app_id);
59 58 client
60 59 }
61 60
@@ -99,7 +98,7 @@ async fn authenticate_success_stores_session() {
99 98 .await
100 99 .unwrap();
101 100
102 - let info = client.session_info().unwrap().expect("session stored");
101 + let info = client.session_info().expect("session stored");
103 102 assert_eq!(info.user_id, user_id);
104 103 assert_eq!(info.app_id, app_id);
105 104 }
@@ -174,7 +173,7 @@ async fn authenticate_with_code_success() {
174 173
175 174 assert_eq!(uid, user_id);
176 175 assert_eq!(aid, app_id);
177 - assert!(client.session_info().unwrap().is_some());
176 + assert!(client.session_info().is_some());
178 177 }
179 178
180 179 // ── Device management ──
@@ -246,7 +245,7 @@ async fn push_encrypts_data() {
246 245
247 246 let client = authed_client(&server);
248 247 let key = synckit_client::crypto::generate_master_key();
249 - client.set_master_key_raw(key).unwrap();
248 + client.set_master_key_raw(key);
250 249
251 250 let device_id = Uuid::new_v4();
252 251 let cursor = client
@@ -285,7 +284,7 @@ async fn pull_decrypts_data() {
285 284
286 285 let client = authed_client(&server);
287 286 let key = synckit_client::crypto::generate_master_key();
288 - client.set_master_key_raw(key).unwrap();
287 + client.set_master_key_raw(key);
289 288
290 289 // Encrypt a value to simulate what the server would return
291 290 let plaintext = json!({"title": "Decrypted task"});
@@ -336,7 +335,7 @@ async fn push_retries_on_503() {
336 335
337 336 let client = authed_client(&server);
338 337 let key = synckit_client::crypto::generate_master_key();
339 - client.set_master_key_raw(key).unwrap();
338 + client.set_master_key_raw(key);
340 339
341 340 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
342 341 assert_eq!(cursor, 5);
@@ -355,7 +354,7 @@ async fn push_fails_immediately_on_401() {
355 354
356 355 let client = authed_client(&server);
357 356 let key = synckit_client::crypto::generate_master_key();
358 - client.set_master_key_raw(key).unwrap();
357 + client.set_master_key_raw(key);
359 358
360 359 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
361 360 assert!(matches!(err, SyncKitError::Server { status: 401, .. }));
@@ -367,7 +366,7 @@ async fn pull_with_has_more_pagination() {
367 366
368 367 let client = authed_client(&server);
369 368 let key = synckit_client::crypto::generate_master_key();
370 - client.set_master_key_raw(key).unwrap();
369 + client.set_master_key_raw(key);
371 370
372 371 let device_id = Uuid::new_v4();
373 372
@@ -438,7 +437,7 @@ async fn blob_upload_encrypts_data() {
438 437
439 438 let client = authed_client(&server);
440 439 let key = synckit_client::crypto::generate_master_key();
441 - client.set_master_key_raw(key).unwrap();
440 + client.set_master_key_raw(key);
442 441
443 442 let plaintext = b"hello blob data";
444 443 let presigned = format!("{}{}", server.uri(), upload_path);
@@ -470,7 +469,7 @@ async fn blob_download_decrypts_data() {
470 469
471 470 let client = authed_client(&server);
472 471 let key = synckit_client::crypto::generate_master_key();
473 - client.set_master_key_raw(key).unwrap();
472 + client.set_master_key_raw(key);
474 473
475 474 // Encrypt data to simulate what S3 would return
476 475 let plaintext = b"decrypted blob content";
@@ -508,7 +507,7 @@ async fn blob_upload_retries_on_503() {
508 507
509 508 let client = authed_client(&server);
510 509 let key = synckit_client::crypto::generate_master_key();
511 - client.set_master_key_raw(key).unwrap();
510 + client.set_master_key_raw(key);
512 511
513 512 let presigned = format!("{}{}", server.uri(), upload_path);
514 513 let result = client.blob_upload(&presigned, b"data".to_vec()).await;
@@ -524,7 +523,7 @@ async fn expired_jwt_returns_token_expired() {
524 523 let (user_id, app_id) = test_ids();
525 524
526 525 let expired = fake_jwt(Utc::now().timestamp() - 3600);
527 - client.restore_session(&expired, user_id, app_id).unwrap();
526 + client.restore_session(&expired, user_id, app_id);
528 527
529 528 let err = client.status().await.unwrap_err();
530 529 assert!(
@@ -542,8 +541,7 @@ async fn near_expiry_jwt_returns_token_expired() {
542 541 // Token expires in 10 seconds (within 30-second buffer)
543 542 let near_expiry = fake_jwt(Utc::now().timestamp() + 10);
544 543 client
545 - .restore_session(&near_expiry, user_id, app_id)
546 - .unwrap();
544 + .restore_session(&near_expiry, user_id, app_id);
547 545
548 546 let err = client.status().await.unwrap_err();
549 547 assert!(
@@ -574,7 +572,7 @@ async fn restore_then_clear_session() {
574 572 assert_eq!(status.total_changes, 0);
575 573
576 574 // Clear session
577 - client.clear_session().unwrap();
575 + client.clear_session();
578 576
579 577 // Now should fail
580 578 let err = client.status().await.unwrap_err();
@@ -681,7 +679,7 @@ async fn concurrent_push_pull_no_panics() {
681 679
682 680 let client = Arc::new(authed_client(&server));
683 681 let key = synckit_client::crypto::generate_master_key();
684 - client.set_master_key_raw(key).unwrap();
682 + client.set_master_key_raw(key);
685 683
686 684 let mut handles = Vec::new();
687 685 for _ in 0..4 {
@@ -838,7 +836,7 @@ async fn setup_change_password_test(
838 836 let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap();
839 837
840 838 // Cache the master key in the client (simulating normal logged-in state)
841 - client.set_master_key_raw(master_key).unwrap();
839 + client.set_master_key_raw(master_key);
842 840
843 841 (client, master_key, envelope)
844 842 }
@@ -1008,7 +1006,7 @@ async fn push_empty_changes_succeeds() {
1008 1006
1009 1007 let client = authed_client(&server);
1010 1008 let key = synckit_client::crypto::generate_master_key();
1011 - client.set_master_key_raw(key).unwrap();
1009 + client.set_master_key_raw(key);
1012 1010
1013 1011 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
1014 1012 assert_eq!(cursor, 0);
@@ -1030,7 +1028,7 @@ async fn push_malformed_json_response_handled() {
1030 1028
1031 1029 let client = authed_client(&server);
1032 1030 let key = synckit_client::crypto::generate_master_key();
1033 - client.set_master_key_raw(key).unwrap();
1031 + client.set_master_key_raw(key);
1034 1032
1035 1033 let result = client.push(Uuid::new_v4(), vec![]).await;
1036 1034 assert!(result.is_err(), "Malformed JSON should produce an error");
@@ -1056,7 +1054,7 @@ async fn pull_malformed_json_response_handled() {
1056 1054
1057 1055 let client = authed_client(&server);
1058 1056 let key = synckit_client::crypto::generate_master_key();
1059 - client.set_master_key_raw(key).unwrap();
1057 + client.set_master_key_raw(key);
1060 1058
1061 1059 let result = client.pull(Uuid::new_v4(), 0).await;
1062 1060 assert!(result.is_err(), "Malformed JSON should produce an error");
@@ -1121,7 +1119,7 @@ async fn concurrent_push_operations_no_data_corruption() {
1121 1119
1122 1120 let client = Arc::new(authed_client(&server));
1123 1121 let key = synckit_client::crypto::generate_master_key();
1124 - client.set_master_key_raw(key).unwrap();
1122 + client.set_master_key_raw(key);
1125 1123
1126 1124 let mut handles = Vec::new();
1127 1125 for i in 0..8 {
@@ -1168,7 +1166,7 @@ async fn concurrent_push_and_pull_interleaved() {
1168 1166
1169 1167 let client = Arc::new(authed_client(&server));
1170 1168 let key = synckit_client::crypto::generate_master_key();
1171 - client.set_master_key_raw(key).unwrap();
1169 + client.set_master_key_raw(key);
1172 1170
1173 1171 let mut handles = Vec::new();
1174 1172 for i in 0..4 {
@@ -1204,7 +1202,7 @@ async fn push_many_changes_succeeds() {
1204 1202
1205 1203 let client = authed_client(&server);
1206 1204 let key = synckit_client::crypto::generate_master_key();
1207 - client.set_master_key_raw(key).unwrap();
1205 + client.set_master_key_raw(key);
1208 1206
1209 1207 // Create 1000+ change entries
1210 1208 let changes: Vec<ChangeEntry> = (0..1100)
@@ -1230,10 +1228,9 @@ async fn expired_token_detected_before_push() {
1230 1228 let (user_id, app_id) = test_ids();
1231 1229
1232 1230 let expired = fake_jwt(Utc::now().timestamp() - 100);
1233 - client.restore_session(&expired, user_id, app_id).unwrap();
1231 + client.restore_session(&expired, user_id, app_id);
1234 1232 client
1235 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
1236 - .unwrap();
1233 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
1237 1234
1238 1235 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
1239 1236 assert!(
@@ -1249,10 +1246,9 @@ async fn expired_token_detected_before_pull() {
1249 1246 let (user_id, app_id) = test_ids();
1250 1247
1251 1248 let expired = fake_jwt(Utc::now().timestamp() - 100);
1252 - client.restore_session(&expired, user_id, app_id).unwrap();
1249 + client.restore_session(&expired, user_id, app_id);
1253 1250 client
1254 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
1255 - .unwrap();
1251 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
1256 1252
1257 1253 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
1258 1254 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1265,7 +1261,7 @@ async fn expired_token_detected_before_register_device() {
1265 1261 let (user_id, app_id) = test_ids();
1266 1262
1267 1263 let expired = fake_jwt(Utc::now().timestamp() - 100);
1268 - client.restore_session(&expired, user_id, app_id).unwrap();
1264 + client.restore_session(&expired, user_id, app_id);
1269 1265
1270 1266 let err = client.register_device("Test", "test").await.unwrap_err();
1271 1267 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1278,7 +1274,7 @@ async fn expired_token_detected_before_list_devices() {
1278 1274 let (user_id, app_id) = test_ids();
1279 1275
1280 1276 let expired = fake_jwt(Utc::now().timestamp() - 100);
1281 - client.restore_session(&expired, user_id, app_id).unwrap();
1277 + client.restore_session(&expired, user_id, app_id);
1282 1278
1283 1279 let err = client.list_devices().await.unwrap_err();
1284 1280 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1299,7 +1295,7 @@ async fn blob_upload_zero_byte_data() {
1299 1295
1300 1296 let client = authed_client(&server);
1301 1297 let key = synckit_client::crypto::generate_master_key();
1302 - client.set_master_key_raw(key).unwrap();
1298 + client.set_master_key_raw(key);
1303 1299
1304 1300 let presigned = format!("{}{}", server.uri(), upload_path);
1305 1301 let result = client.blob_upload(&presigned, vec![]).await;
@@ -1324,7 +1320,7 @@ async fn blob_upload_download_roundtrip() {
1324 1320
1325 1321 let client = authed_client(&server);
1326 1322 let key = synckit_client::crypto::generate_master_key();
1327 - client.set_master_key_raw(key).unwrap();
1323 + client.set_master_key_raw(key);
1328 1324
1329 1325 let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01";
1330 1326
@@ -1471,7 +1467,7 @@ async fn double_push_same_data_both_succeed() {
1471 1467
1472 1468 let client = authed_client(&server);
1473 1469 let key = synckit_client::crypto::generate_master_key();
1474 - client.set_master_key_raw(key).unwrap();
1470 + client.set_master_key_raw(key);
1475 1471
1476 1472 let entry = ChangeEntry {
1477 1473 table: "tasks".into(),
@@ -1505,12 +1501,12 @@ async fn setup_encryption_new_stores_key_and_uploads_envelope() {
1505 1501 .await;
1506 1502
1507 1503 let client = authed_client(&server);
1508 - assert!(!client.has_master_key().unwrap());
1504 + assert!(!client.has_master_key());
1509 1505
1510 1506 client.setup_encryption_new("test-password").await.unwrap();
1511 1507
1512 1508 // Master key should now be in memory
1513 - assert!(client.has_master_key().unwrap());
1509 + assert!(client.has_master_key());
1514 1510
1515 1511 // Verify the PUT body contains a valid envelope unwrappable with the same password
1516 1512 let requests = server.received_requests().await.unwrap();
@@ -1557,7 +1553,7 @@ async fn setup_encryption_new_retries_on_server_error() {
1557 1553 let client = authed_client(&server);
1558 1554 let result = client.setup_encryption_new("password").await;
1559 1555 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1560 - assert!(client.has_master_key().unwrap());
1556 + assert!(client.has_master_key());
1561 1557 }
1562 1558
1563 1559 #[tokio::test]
@@ -1578,14 +1574,14 @@ async fn setup_encryption_existing_recovers_key() {
1578 1574 .await;
1579 1575
1580 1576 let client = authed_client(&server);
1581 - assert!(!client.has_master_key().unwrap());
1577 + assert!(!client.has_master_key());
1582 1578
1583 1579 client
1584 1580 .setup_encryption_existing("my-password")
1585 1581 .await
1586 1582 .unwrap();
1587 1583
1588 - assert!(client.has_master_key().unwrap());
1584 + assert!(client.has_master_key());
1589 1585 }
1590 1586
1591 1587 #[tokio::test]
@@ -1614,7 +1610,7 @@ async fn setup_encryption_existing_wrong_password_fails() {
1614 1610 matches!(err, SyncKitError::DecryptionFailed),
1615 1611 "Wrong password should produce DecryptionFailed: {err:?}"
1616 1612 );
1617 - assert!(!client.has_master_key().unwrap());
1613 + assert!(!client.has_master_key());
1618 1614 }
1619 1615
1620 1616 #[tokio::test]
@@ -1656,7 +1652,7 @@ async fn setup_encryption_existing_retries_on_server_error() {
1656 1652 let client = authed_client(&server);
1657 1653 let result = client.setup_encryption_existing("password").await;
1658 1654 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1659 - assert!(client.has_master_key().unwrap());
1655 + assert!(client.has_master_key());
1660 1656 }
1661 1657
1662 1658 #[tokio::test]
@@ -1895,7 +1891,7 @@ async fn blob_download_with_wrong_key_fails() {
1895 1891
1896 1892 // Client has key2 (wrong key)
1897 1893 let client = authed_client(&server);
1898 - client.set_master_key_raw(key2).unwrap();
1894 + client.set_master_key_raw(key2);
1899 1895
1900 1896 let result = client
1901 1897 .blob_download(&format!("{}{}", server.uri(), download_path))
@@ -1952,7 +1948,7 @@ async fn end_to_end_push_pull_encryption_roundtrip() {
1952 1948
1953 1949 let client = authed_client(&server);
1954 1950 let key = synckit_client::crypto::generate_master_key();
1955 - client.set_master_key_raw(key).unwrap();
1951 + client.set_master_key_raw(key);
1956 1952
1957 1953 let device_id = Uuid::new_v4();
1958 1954 let original_data = json!({
@@ -2139,7 +2135,7 @@ async fn push_empty_response_body_returns_error() {
2139 2135
2140 2136 let client = authed_client(&server);
2141 2137 let key = synckit_client::crypto::generate_master_key();
2142 - client.set_master_key_raw(key).unwrap();
2138 + client.set_master_key_raw(key);
2143 2139
2144 2140 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2145 2141 assert!(
@@ -2166,7 +2162,7 @@ async fn pull_response_missing_has_more_returns_error() {
2166 2162
2167 2163 let client = authed_client(&server);
2168 2164 let key = synckit_client::crypto::generate_master_key();
2169 - client.set_master_key_raw(key).unwrap();
2165 + client.set_master_key_raw(key);
2170 2166
2171 2167 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
2172 2168 assert!(
@@ -2290,7 +2286,7 @@ async fn server_returns_413_request_entity_too_large() {
2290 2286
2291 2287 let client = authed_client(&server);
2292 2288 let key = synckit_client::crypto::generate_master_key();
2293 - client.set_master_key_raw(key).unwrap();
2289 + client.set_master_key_raw(key);
2294 2290
2295 2291 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2296 2292 match err {
@@ -2342,7 +2338,7 @@ async fn double_authenticate_overwrites_session() {
2342 2338 assert_eq!(uid2, second_user_id);
2343 2339
2344 2340 // Session should now reflect the second auth
2345 - let info = client.session_info().unwrap().unwrap();
2341 + let info = client.session_info().unwrap();
2346 2342 assert_eq!(info.user_id, second_user_id);
2347 2343 }
2348 2344
@@ -2357,15 +2353,15 @@ async fn clear_session_then_authenticate_succeeds() {
2357 2353 .await;
2358 2354
2359 2355 let client = authed_client(&server);
2360 - assert!(client.session_info().unwrap().is_some());
2356 + assert!(client.session_info().is_some());
2361 2357
2362 - client.clear_session().unwrap();
2363 - assert!(client.session_info().unwrap().is_none());
2358 + client.clear_session();
2359 + assert!(client.session_info().is_none());
2364 2360
2365 2361 // Re-authenticate should work
2366 2362 let result = client.authenticate("user@test.com", "pass").await;
2367 2363 assert!(result.is_ok(), "Should be able to re-authenticate after clear: {result:?}");
2368 - assert!(client.session_info().unwrap().is_some());
2364 + assert!(client.session_info().is_some());
2369 2365 }
2370 2366
2371 2367 #[tokio::test]
@@ -2375,10 +2371,9 @@ async fn restore_session_with_expired_token_then_push_returns_token_expired() {
2375 2371 let (user_id, app_id) = test_ids();
2376 2372
2377 2373 let expired = fake_jwt(Utc::now().timestamp() - 3600);
2378 - client.restore_session(&expired, user_id, app_id).unwrap();
2374 + client.restore_session(&expired, user_id, app_id);
2379 2375 client
2380 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
2381 - .unwrap();
2376 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
2382 2377
2383 2378 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2384 2379 assert!(
@@ -2402,11 +2397,11 @@ async fn setup_encryption_new_twice_overwrites() {
2402 2397 let client = authed_client(&server);
2403 2398
2404 2399 client.setup_encryption_new("pass1").await.unwrap();
2405 - assert!(client.has_master_key().unwrap());
2400 + assert!(client.has_master_key());
2406 2401
2407 2402 // Second call overwrites
2408 2403 client.setup_encryption_new("pass2").await.unwrap();
2409 - assert!(client.has_master_key().unwrap());
2404 + assert!(client.has_master_key());
2410 2405
2411 2406 // Verify the second PUT used a different envelope
2412 2407 let requests = server.received_requests().await.unwrap();
@@ -2449,7 +2444,7 @@ async fn blob_download_retries_on_503() {
2449 2444
2450 2445 let client = authed_client(&server);
2451 2446 let key = synckit_client::crypto::generate_master_key();
2452 - client.set_master_key_raw(key).unwrap();
2447 + client.set_master_key_raw(key);
2453 2448
2454 2449 let plaintext = b"retry download test";
2455 2450 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
@@ -2486,7 +2481,7 @@ async fn blob_upload_1mb_with_correct_overhead() {
2486 2481
2487 2482 let client = authed_client(&server);
2488 2483 let key = synckit_client::crypto::generate_master_key();
2489 - client.set_master_key_raw(key).unwrap();
2484 + client.set_master_key_raw(key);
2490 2485
2491 2486 let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect();
2492 2487 let presigned = format!("{}{}", server.uri(), upload_path);
@@ -2574,7 +2569,7 @@ async fn concurrent_session_info_reads() {
2574 2569 let mut handles = Vec::new();
2575 2570 for _ in 0..50 {
2576 2571 let c = Arc::clone(&client);
2577 - handles.push(tokio::spawn(async move { c.session_info().unwrap() }));
2572 + handles.push(tokio::spawn(async move { c.session_info() }));
2578 2573 }
2579 2574
2580 2575 for h in handles {
@@ -2588,13 +2583,12 @@ async fn concurrent_has_master_key_reads() {
2588 2583 let server = MockServer::start().await;
2589 2584 let client = Arc::new(authed_client(&server));
2590 2585 client
2591 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
2592 - .unwrap();
2586 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
2593 2587
2594 2588 let mut handles = Vec::new();
2595 2589 for _ in 0..50 {
2596 2590 let c = Arc::clone(&client);
2597 - handles.push(tokio::spawn(async move { c.has_master_key().unwrap() }));
2591 + handles.push(tokio::spawn(async move { c.has_master_key() }));
2598 2592 }
2599 2593
2600 2594 for h in handles {
@@ -2643,7 +2637,7 @@ async fn concurrent_push_100_entries_each() {
2643 2637
2644 2638 let client = Arc::new(authed_client(&server));
2645 2639 let key = synckit_client::crypto::generate_master_key();
2646 - client.set_master_key_raw(key).unwrap();
2640 + client.set_master_key_raw(key);
2647 2641
2648 2642 let mut handles = Vec::new();
2649 2643 for batch in 0..4 {
@@ -2689,8 +2683,7 @@ fn authed_short_timeout_client(server: &MockServer) -> SyncKitClient {
2689 2683 let client = short_timeout_client(server);
2690 2684 let (user_id, app_id) = test_ids();
2691 2685 client
2692 - .restore_session(&fresh_token(), user_id, app_id)
2693 - .unwrap();
2686 + .restore_session(&fresh_token(), user_id, app_id);
2694 2687 client
2695 2688 }
2696 2689
@@ -2742,7 +2735,7 @@ async fn push_retries_on_timeout_then_succeeds() {
2742 2735
2743 2736 let client = authed_short_timeout_client(&server);
2744 2737 let key = synckit_client::crypto::generate_master_key();
2745 - client.set_master_key_raw(key).unwrap();
2738 + client.set_master_key_raw(key);
2746 2739
2747 2740 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
2748 2741 assert_eq!(cursor, 42);