max / synckit
9 files changed,
+59 insertions,
-46 deletions
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | //! This module provides: | |
| 8 | 8 | //! - [`detect_conflicts`]: pure function that splits pulled changes into clean | |
| 9 | 9 | //! (non-conflicting) and conflicting sets | |
| 10 | - | //! - [`resolve_lww`]: last-write-wins resolution by `client_timestamp` | |
| 10 | + | //! - [`resolve_lww`]: last-write-wins resolution by hybrid logical clock | |
| 11 | 11 | //! - [`resolve_field_merge`]: 3-way JSON object merge using a base version | |
| 12 | 12 | //! - [`ConflictResolver`]: trait for custom resolution strategies | |
| 13 | 13 | ||
| @@ -248,8 +248,8 @@ | |||
| 248 | 248 | /// | |
| 249 | 249 | /// This replaces the prior wall-clock + "delete always wins" rule. Delete-wins | |
| 250 | 250 | /// caused permanent loss when a delete on one device beat a strictly-newer edit on | |
| 251 | - | /// another (ultra-fuzz Run #28); under HLC ordering a newer edit beats an older | |
| 252 | - | /// delete and vice-versa, by clock value alone. | |
| 251 | + | /// another; under HLC ordering a newer edit beats an older delete and vice-versa, | |
| 252 | + | /// by clock value alone. | |
| 253 | 253 | /// | |
| 254 | 254 | /// An *exactly* equal HLC normally means a same-device echo, since a distinct | |
| 255 | 255 | /// `node` makes the value unique. It can only collide across devices if two |
| @@ -2,34 +2,20 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! Feature-gated behind `keychain` (enabled by default). | |
| 4 | 4 | //! | |
| 5 | - | //! The keychain is a *cache*, never the system of record: the master key is | |
| 6 | - | //! always recoverable from the server envelope with the user's password. So a | |
| 7 | - | //! keychain that is missing, locked, or absent entirely costs a password | |
| 8 | - | //! re-entry, not access to the data. Callers reflect | |
| 9 | - | //! that: see [`cache_key`], which reports failure rather than propagating it. | |
| 10 | - | //! | |
| 11 | - | //! ## Platform backends | |
| 12 | - | //! | |
| 13 | - | //! - **macOS**: legacy Keychain (Security framework), via `keyring`'s `v1` store. | |
| 14 | - | //! - **iOS**: Protected Data store (`kSecClassGenericPassword`), installed by | |
| 15 | - | //! this module. `keyring`'s `v1` helper cannot do it (its store installer is | |
| 16 | - | //! cfg'd out on iOS), so [`entry`] calls `keyring_core::set_default_store` | |
| 17 | - | //! directly with `apple_native_keyring_store::protected::Store`. Entries are | |
| 18 | - | //! created with an explicit [`IOS_ACCESS_POLICY`] rather than the store | |
| 19 | - | //! default; see that constant for why. | |
| 20 | - | //! - **Linux**: secret-service (D-Bus). Requires a running keyring daemon such | |
| 21 | - | //! as gnome-keyring. Without a secret-service provider, entry construction | |
| 22 | - | //! fails and the cache is simply unavailable. | |
| 23 | - | //! - **Windows**: Credential Manager. | |
| 5 | + | //! The per-platform backends, the cache-not-system-of-record rule, and the iOS | |
| 6 | + | //! accessibility-class rationale are in `docs/architecture.md` ("Keychain | |
| 7 | + | //! integration"). Two consequences show up in this file: [`cache_key`] reports | |
| 8 | + | //! failure rather than propagating it, and [`entry`] installs the iOS store | |
| 9 | + | //! itself with an explicit `IOS_ACCESS_POLICY`. | |
| 24 | 10 | ||
| 25 | 11 | use crate::error::Result; | |
| 26 | 12 | // Only the keychain code paths (and the test module) construct SyncKitError | |
| 27 | 13 | // directly; without the feature a plain lib build would see it as unused. | |
| 28 | 14 | #[cfg(any(feature = "keychain", test))] | |
| 29 | 15 | use crate::error::SyncKitError; | |
| 16 | + | use crate::ids::{AppId, UserId}; | |
| 30 | 17 | // Only the keychain code paths use this top-level alias; the test module imports | |
| 31 | 18 | // its own `B64` locally, so a no-keychain test build must not pull it in here. | |
| 32 | - | use crate::ids::{AppId, UserId}; | |
| 33 | 19 | #[cfg(feature = "keychain")] | |
| 34 | 20 | use base64::{Engine, engine::general_purpose::STANDARD as B64}; | |
| 35 | 21 | #[cfg(test)] | |
| @@ -50,9 +36,6 @@ | |||
| 50 | 36 | } | |
| 51 | 37 | ||
| 52 | 38 | /// Build the keychain user key: the `user_id` as a hyphenated UUID string. | |
| 53 | - | /// | |
| 54 | - | /// Combined with `service_name`, this uniquely identifies the keychain entry | |
| 55 | - | /// for a given (app, user) pair. | |
| 56 | 39 | #[cfg(any(feature = "keychain", test))] | |
| 57 | 40 | fn user_key(user_id: UserId) -> String { | |
| 58 | 41 | user_id.to_string() |
| @@ -7,6 +7,8 @@ | |||
| 7 | 7 | //! `SyncTable`, but nothing here depends on it, so a no-sync consumer (the Alloy | |
| 8 | 8 | //! console remembering a theme) links this crate and not the sync runtime. | |
| 9 | 9 | //! | |
| 10 | + | //! <!-- wiki: synckit-config-design --> | |
| 11 | + | //! | |
| 10 | 12 | //! ## Why this is its own crate, and why it is SQLite | |
| 11 | 13 | //! | |
| 12 | 14 | //! Four apps stored settings four ways (a synced SQLite table, an |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | //! A [`ConfigSpec`] is the source of truth for postures: a static list of the | |
| 4 | 4 | //! keys an app knows, each tagged [`Synced`] or [`Local`]. It is pure data with | |
| 5 | 5 | //! no connection and no I/O, so it can be a `const` an app declares once. The | |
| 6 | - | //! sync side ([`synckit-client`]) reads [`policy_rows`](ConfigSpec::policy_rows) | |
| 6 | + | //! sync side (`synckit-client`) reads [`policy_rows`](ConfigSpec::policy_rows) | |
| 7 | 7 | //! to seed the filter that keeps `Local` keys off the wire. | |
| 8 | 8 | //! | |
| 9 | 9 | //! [`Synced`]: Posture::Synced |
| @@ -70,7 +70,6 @@ | |||
| 70 | 70 | // on next cold launch, not the setup. | |
| 71 | 71 | keystore::cache_key(app_id, user_id, &master_key.0); | |
| 72 | 72 | ||
| 73 | - | // Store in memory | |
| 74 | 73 | *self.master_key.write() = Some(master_key); | |
| 75 | 74 | ||
| 76 | 75 | tracing::info!("New master key generated and stored"); | |
| @@ -92,7 +91,6 @@ | |||
| 92 | 91 | // server envelope and can come from it again. | |
| 93 | 92 | keystore::cache_key(app_id, user_id, &master_key.0); | |
| 94 | 93 | ||
| 95 | - | // Store in memory | |
| 96 | 94 | *self.master_key.write() = Some(master_key); | |
| 97 | 95 | ||
| 98 | 96 | tracing::info!("Master key recovered from server"); |
| @@ -25,13 +25,38 @@ | |||
| 25 | 25 | //! [`blob_confirm`](SyncKitClient::blob_confirm), | |
| 26 | 26 | //! [`blob_download_url`](SyncKitClient::blob_download_url), | |
| 27 | 27 | //! [`blob_download`](SyncKitClient::blob_download). | |
| 28 | + | //! - **Groups**: [`create_group`](SyncKitClient::create_group), | |
| 29 | + | //! [`list_groups`](SyncKitClient::list_groups), | |
| 30 | + | //! [`add_member`](SyncKitClient::add_member), | |
| 31 | + | //! [`remove_member`](SyncKitClient::remove_member), | |
| 32 | + | //! [`rotate_group_key`](SyncKitClient::rotate_group_key), | |
| 33 | + | //! [`group_push`](SyncKitClient::group_push), | |
| 34 | + | //! [`group_pull_rich`](SyncKitClient::group_pull_rich), and the invitation | |
| 35 | + | //! calls ([`create_invitation`](SyncKitClient::create_invitation), | |
| 36 | + | //! [`accept_invitation`](SyncKitClient::accept_invitation)). | |
| 37 | + | //! - **Key rotation**: [`rotate_key`](SyncKitClient::rotate_key). | |
| 38 | + | //! - **Change notifications**: [`subscribe`](SyncKitClient::subscribe), an SSE | |
| 39 | + | //! stream of [`SyncNotifyStream`]. | |
| 40 | + | //! - **Billing**: [`get_app_pricing`](SyncKitClient::get_app_pricing), | |
| 41 | + | //! [`quote_price`](SyncKitClient::quote_price), | |
| 42 | + | //! [`get_subscription_status`](SyncKitClient::get_subscription_status), | |
| 43 | + | //! [`create_subscription_checkout`](SyncKitClient::create_subscription_checkout), | |
| 44 | + | //! [`queue_storage_cap_change`](SyncKitClient::queue_storage_cap_change). | |
| 45 | + | //! - **OTA publishing**: [`ota_create_release`](SyncKitClient::ota_create_release), | |
| 46 | + | //! [`ota_register_artifact`](SyncKitClient::ota_register_artifact), | |
| 47 | + | //! [`ota_upload_artifact`](SyncKitClient::ota_upload_artifact), | |
| 48 | + | //! [`ota_confirm_artifact`](SyncKitClient::ota_confirm_artifact), | |
| 49 | + | //! [`ota_updater_check`](SyncKitClient::ota_updater_check). | |
| 28 | 50 | //! | |
| 29 | 51 | //! ## Internal state | |
| 30 | 52 | //! | |
| 31 | - | //! The client holds two `RwLock`-wrapped fields: the authenticated session | |
| 32 | - | //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both | |
| 33 | - | //! start as `None` and are populated by the authentication and encryption | |
| 34 | - | //! setup methods respectively. | |
| 53 | + | //! The client holds six `RwLock`-wrapped fields: the authenticated session | |
| 54 | + | //! (JWT token, user ID, app ID), the 256-bit master encryption key, the | |
| 55 | + | //! `key_id` that key was issued under, the pending rotation key, the decrypted | |
| 56 | + | //! Group Content Key cache, and the optional blob-resume store. The session and | |
| 57 | + | //! master key start as `None` and are populated by the authentication and | |
| 58 | + | //! encryption setup methods respectively; the rotation and group fields carry | |
| 59 | + | //! key material with their own lifecycles, documented on the struct. | |
| 35 | 60 | //! | |
| 36 | 61 | //! ## Thread safety | |
| 37 | 62 | //! | |
| @@ -41,9 +66,13 @@ | |||
| 41 | 66 | //! | |
| 42 | 67 | //! ## Retry strategy | |
| 43 | 68 | //! | |
| 44 | - | //! All HTTP operations retry transient failures (network errors, 5xx, | |
| 45 | - | //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors | |
| 46 | - | //! (4xx except 429) are permanent and returned immediately. | |
| 69 | + | //! Retries apply only to calls the call site marks idempotent-safe. Those | |
| 70 | + | //! retry transient failures (network errors, 5xx, 429) up to 3 times with | |
| 71 | + | //! exponential backoff (1s, 2s, 4s) spread by +/-20% jitter; a `Retry-After` | |
| 72 | + | //! header on a 429 overrides the computed delay, capped at 60 seconds. Calls | |
| 73 | + | //! marked unsafe to replay (Stripe checkout creation, for one) are attempted | |
| 74 | + | //! exactly once. Client errors (4xx except 429) are permanent and returned | |
| 75 | + | //! immediately. | |
| 47 | 76 | //! | |
| 48 | 77 | //! ## Token handling | |
| 49 | 78 | //! |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | //! The sync half of portable config: a [`ConfigSpec`] as a [`SyncTable`]. | |
| 2 | 2 | //! | |
| 3 | - | //! [`synckit-config`] holds the local store an app reads and writes and the | |
| 3 | + | //! `synckit-config` holds the local store an app reads and writes and the | |
| 4 | 4 | //! posture declaration; this turns that declaration into the sync engine's | |
| 5 | 5 | //! terms. An app calls [`config_sync_table`] to fold config into its | |
| 6 | 6 | //! [`SyncSchema`] and [`install_policy`] once at open to seed the filter the |
| @@ -4,14 +4,15 @@ | |||
| 4 | 4 | //! hand-writes, the changelog schema and triggers, push/pull loops, FK-ordered | |
| 5 | 5 | //! apply, HLC, blobs, and the scheduler, behind a declarative [`SyncSchema`]. | |
| 6 | 6 | //! An app declares its tables and per-table policy once; the engine owns the | |
| 7 | - | //! mechanics. See `docs/architecture.md` ("Proposed: the `SyncStore` helper"). | |
| 7 | + | //! mechanics. See `docs/architecture.md` ("The `SyncStore` higher-level | |
| 8 | + | //! helper"). | |
| 8 | 9 | //! | |
| 9 | - | //! Build status: **complete (B1–B7)**, schema types ([`schema`]), trigger/ | |
| 10 | - | //! migration generation ([`migrate`]), the DB seam + bookkeeping state ([`db`]), | |
| 11 | - | //! the FK-ordered apply engine ([`apply`]), the HLC ledger + conflict dispatch | |
| 12 | - | //! ([`hlc`]), the push/pull loops + lifecycle ([`sync`]), the blob engine | |
| 13 | - | //! ([`blob`]), and the [`SyncStore`](facade::SyncStore) facade + scheduler | |
| 14 | - | //! ([`facade`], [`scheduler`]). | |
| 10 | + | //! The modules: schema types ([`schema`]), trigger/migration generation | |
| 11 | + | //! ([`migrate`]), the DB seam + bookkeeping state ([`db`]), the FK-ordered apply | |
| 12 | + | //! engine ([`apply`]), the HLC ledger + conflict dispatch ([`hlc`]), the | |
| 13 | + | //! push/pull loops + lifecycle ([`sync`]), the blob engine ([`blob`]), and the | |
| 14 | + | //! [`SyncStore`](facade::SyncStore) facade + scheduler ([`facade`], | |
| 15 | + | //! [`scheduler`]). | |
| 15 | 16 | //! | |
| 16 | 17 | //! Start at [`SyncStore`](facade::SyncStore): declare a [`SyncSchema`](schema), | |
| 17 | 18 | //! build a store, and call `sync_now` or `spawn_scheduler`. |
| @@ -8,8 +8,8 @@ | |||
| 8 | 8 | //! trigger/snapshot/whitelist triplication (and its silent-drift bug class) | |
| 9 | 9 | //! cannot recur. | |
| 10 | 10 | //! | |
| 11 | - | //! See `docs/architecture.md` ("Proposed: the `SyncStore` helper") for the full | |
| 12 | - | //! design and worked GoingsOn / audiofiles / Balanced Breakfast manifests. | |
| 11 | + | //! See `docs/architecture.md` ("The `SyncStore` higher-level helper") for the | |
| 12 | + | //! full design and worked GoingsOn / audiofiles / Balanced Breakfast manifests. | |
| 13 | 13 | ||
| 14 | 14 | /// How the engine resolves concurrent edits to the same row. | |
| 15 | 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |