max / balanced_breakfast
6 files changed,
+111 insertions,
-26 deletions
| @@ -1188,6 +1188,18 @@ dependencies = [ | |||
| 1188 | 1188 | ] | |
| 1189 | 1189 | ||
| 1190 | 1190 | [[package]] | |
| 1191 | + | name = "fallible-iterator" | |
| 1192 | + | version = "0.3.0" | |
| 1193 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1194 | + | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 1195 | + | ||
| 1196 | + | [[package]] | |
| 1197 | + | name = "fallible-streaming-iterator" | |
| 1198 | + | version = "0.1.9" | |
| 1199 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1200 | + | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 1201 | + | ||
| 1202 | + | [[package]] | |
| 1191 | 1203 | name = "fastrand" | |
| 1192 | 1204 | version = "2.3.0" | |
| 1193 | 1205 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1769,6 +1781,15 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" | |||
| 1769 | 1781 | ||
| 1770 | 1782 | [[package]] | |
| 1771 | 1783 | name = "hashbrown" | |
| 1784 | + | version = "0.14.5" | |
| 1785 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1786 | + | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | |
| 1787 | + | dependencies = [ | |
| 1788 | + | "ahash", | |
| 1789 | + | ] | |
| 1790 | + | ||
| 1791 | + | [[package]] | |
| 1792 | + | name = "hashbrown" | |
| 1772 | 1793 | version = "0.15.5" | |
| 1773 | 1794 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1774 | 1795 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| @@ -1786,6 +1807,15 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |||
| 1786 | 1807 | ||
| 1787 | 1808 | [[package]] | |
| 1788 | 1809 | name = "hashlink" | |
| 1810 | + | version = "0.9.1" | |
| 1811 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1812 | + | checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" | |
| 1813 | + | dependencies = [ | |
| 1814 | + | "hashbrown 0.14.5", | |
| 1815 | + | ] | |
| 1816 | + | ||
| 1817 | + | [[package]] | |
| 1818 | + | name = "hashlink" | |
| 1789 | 1819 | version = "0.10.0" | |
| 1790 | 1820 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1791 | 1821 | checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" | |
| @@ -3902,6 +3932,7 @@ dependencies = [ | |||
| 3902 | 3932 | "bytes", | |
| 3903 | 3933 | "encoding_rs", | |
| 3904 | 3934 | "futures-core", | |
| 3935 | + | "futures-util", | |
| 3905 | 3936 | "h2", | |
| 3906 | 3937 | "http", | |
| 3907 | 3938 | "http-body", | |
| @@ -3923,12 +3954,14 @@ dependencies = [ | |||
| 3923 | 3954 | "sync_wrapper", | |
| 3924 | 3955 | "tokio", | |
| 3925 | 3956 | "tokio-native-tls", | |
| 3957 | + | "tokio-util", | |
| 3926 | 3958 | "tower", | |
| 3927 | 3959 | "tower-http", | |
| 3928 | 3960 | "tower-service", | |
| 3929 | 3961 | "url", | |
| 3930 | 3962 | "wasm-bindgen", | |
| 3931 | 3963 | "wasm-bindgen-futures", | |
| 3964 | + | "wasm-streams 0.4.2", | |
| 3932 | 3965 | "web-sys", | |
| 3933 | 3966 | ] | |
| 3934 | 3967 | ||
| @@ -3967,7 +4000,7 @@ dependencies = [ | |||
| 3967 | 4000 | "url", | |
| 3968 | 4001 | "wasm-bindgen", | |
| 3969 | 4002 | "wasm-bindgen-futures", | |
| 3970 | - | "wasm-streams", | |
| 4003 | + | "wasm-streams 0.5.0", | |
| 3971 | 4004 | "web-sys", | |
| 3972 | 4005 | ] | |
| 3973 | 4006 | ||
| @@ -4066,6 +4099,20 @@ dependencies = [ | |||
| 4066 | 4099 | ] | |
| 4067 | 4100 | ||
| 4068 | 4101 | [[package]] | |
| 4102 | + | name = "rusqlite" | |
| 4103 | + | version = "0.32.1" | |
| 4104 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4105 | + | checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" | |
| 4106 | + | dependencies = [ | |
| 4107 | + | "bitflags 2.10.0", | |
| 4108 | + | "fallible-iterator", | |
| 4109 | + | "fallible-streaming-iterator", | |
| 4110 | + | "hashlink 0.9.1", | |
| 4111 | + | "libsqlite3-sys", | |
| 4112 | + | "smallvec", | |
| 4113 | + | ] | |
| 4114 | + | ||
| 4115 | + | [[package]] | |
| 4069 | 4116 | name = "rustc-hash" | |
| 4070 | 4117 | version = "2.1.2" | |
| 4071 | 4118 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4809,7 +4856,7 @@ dependencies = [ | |||
| 4809 | 4856 | "futures-io", | |
| 4810 | 4857 | "futures-util", | |
| 4811 | 4858 | "hashbrown 0.15.5", | |
| 4812 | - | "hashlink", | |
| 4859 | + | "hashlink 0.10.0", | |
| 4813 | 4860 | "indexmap 2.13.0", | |
| 4814 | 4861 | "log", | |
| 4815 | 4862 | "memchr", | |
| @@ -5102,7 +5149,7 @@ dependencies = [ | |||
| 5102 | 5149 | ||
| 5103 | 5150 | [[package]] | |
| 5104 | 5151 | name = "synckit-client" | |
| 5105 | - | version = "0.4.0" | |
| 5152 | + | version = "0.6.0" | |
| 5106 | 5153 | dependencies = [ | |
| 5107 | 5154 | "argon2", | |
| 5108 | 5155 | "base64 0.22.1", | |
| @@ -5113,6 +5160,7 @@ dependencies = [ | |||
| 5113 | 5160 | "parking_lot", | |
| 5114 | 5161 | "rand 0.9.4", | |
| 5115 | 5162 | "reqwest 0.12.28", | |
| 5163 | + | "rusqlite", | |
| 5116 | 5164 | "serde", | |
| 5117 | 5165 | "serde_json", | |
| 5118 | 5166 | "sha2", | |
| @@ -6394,6 +6442,19 @@ dependencies = [ | |||
| 6394 | 6442 | ||
| 6395 | 6443 | [[package]] | |
| 6396 | 6444 | name = "wasm-streams" | |
| 6445 | + | version = "0.4.2" | |
| 6446 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6447 | + | checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" | |
| 6448 | + | dependencies = [ | |
| 6449 | + | "futures-util", | |
| 6450 | + | "js-sys", | |
| 6451 | + | "wasm-bindgen", | |
| 6452 | + | "wasm-bindgen-futures", | |
| 6453 | + | "web-sys", | |
| 6454 | + | ] | |
| 6455 | + | ||
| 6456 | + | [[package]] | |
| 6457 | + | name = "wasm-streams" | |
| 6397 | 6458 | version = "0.5.0" | |
| 6398 | 6459 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6399 | 6460 | checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" |
| @@ -89,6 +89,9 @@ serde_json.workspace = true | |||
| 89 | 89 | roxmltree.workspace = true | |
| 90 | 90 | toml.workspace = true | |
| 91 | 91 | wiremock.workspace = true | |
| 92 | + | # Enables synckit-client's `testing` feature (set_master_key_raw) for tests | |
| 93 | + | # only. Resolver 2 keeps this out of normal/release builds. | |
| 94 | + | synckit-client = { workspace = true, features = ["testing"] } | |
| 92 | 95 | ||
| 93 | 96 | [features] | |
| 94 | 97 | default = ["custom-protocol"] |
| @@ -444,13 +444,15 @@ | |||
| 444 | 444 | // Fetch dynamic pricing | |
| 445 | 445 | let pricingHtml = '<p>Loading pricing...</p>'; | |
| 446 | 446 | try { | |
| 447 | - | const tiers = await BB.api.sync.getTiers(); | |
| 448 | - | if (tiers.length > 0) { | |
| 449 | - | const t = tiers[0]; | |
| 450 | - | const monthly = '$' + (t.monthly_price_cents / 100); | |
| 451 | - | const annual = '$' + (t.annual_price_cents / 100); | |
| 452 | - | const monthlyCost = (t.monthly_price_cents / 100) * 12; | |
| 453 | - | const savings = monthlyCost - (t.annual_price_cents / 100); | |
| 447 | + | // The server returns a pricing formula, not a tier list. BB | |
| 448 | + | // syncs metadata only (cap pinned to the 10 GiB minimum), so | |
| 449 | + | // the price is the formula floor (`min_charge_cents`). | |
| 450 | + | const pricing = await BB.api.sync.getTiers(); | |
| 451 | + | if (pricing && pricing.min_charge_cents != null) { | |
| 452 | + | const monthly = '$' + (pricing.min_charge_cents / 100); | |
| 453 | + | const annual = '$' + ((pricing.min_charge_cents * pricing.annual_multiplier) / 100); | |
| 454 | + | const monthlyCost = (pricing.min_charge_cents / 100) * 12; | |
| 455 | + | const savings = monthlyCost - ((pricing.min_charge_cents * pricing.annual_multiplier) / 100); | |
| 454 | 456 | // F4 (2026-06-02): inline styles moved into | |
| 455 | 457 | // .sync-sub-fine-print and .sync-sub-actions rules. | |
| 456 | 458 | pricingHtml = |
| @@ -288,17 +288,19 @@ fn start_callback_server() -> Result<u16, ApiError> { | |||
| 288 | 288 | ||
| 289 | 289 | // ── Commands ── | |
| 290 | 290 | ||
| 291 | - | /// Fetch available pricing tiers for this app (no auth required, uses API key). | |
| 291 | + | /// Fetch the pricing formula for this app (no auth required, uses API key). | |
| 292 | + | /// The server replaced the fixed tier list with a formula; Balanced Breakfast | |
| 293 | + | /// syncs metadata only (no blob storage), so it presents the formula's minimum | |
| 294 | + | /// as a single suggested price rather than a cap slider. | |
| 292 | 295 | #[tauri::command] | |
| 293 | 296 | #[instrument(skip_all)] | |
| 294 | 297 | pub async fn sync_get_tiers( | |
| 295 | 298 | state: State<'_, Arc<AppState>>, | |
| 296 | - | ) -> Result<Vec<synckit_client::TierInfo>, ApiError> { | |
| 299 | + | ) -> Result<synckit_client::AppPricing, ApiError> { | |
| 297 | 300 | let client = require_sync_client(&state)?; | |
| 298 | - | let tiers = client.get_available_tiers() | |
| 301 | + | client.get_app_pricing() | |
| 299 | 302 | .await | |
| 300 | - | .map_err(|e| ApiError::internal(format!("Failed to fetch tiers: {e}")))?; | |
| 301 | - | Ok(tiers) | |
| 303 | + | .map_err(|e| ApiError::internal(format!("Failed to fetch pricing: {e}"))) | |
| 302 | 304 | } | |
| 303 | 305 | ||
| 304 | 306 | #[tauri::command] | |
| @@ -566,8 +568,13 @@ pub async fn sync_subscribe( | |||
| 566 | 568 | return Err(ApiError::bad_request("Not authenticated")); | |
| 567 | 569 | } | |
| 568 | 570 | ||
| 571 | + | // Balanced Breakfast syncs metadata only (no blob storage), so the cap is | |
| 572 | + | // pinned to the formula's 10 GiB minimum, which trips the base monthly | |
| 573 | + | // floor. The cap is required by the new API but cosmetic for a non-blob app. | |
| 574 | + | let interval_enum = synckit_client::BillingInterval::from_wire(&interval); | |
| 575 | + | const BB_DEFAULT_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; | |
| 569 | 576 | let response = client | |
| 570 | - | .create_subscription_checkout("standard", &interval) | |
| 577 | + | .create_subscription_checkout(BB_DEFAULT_CAP_BYTES, interval_enum) | |
| 571 | 578 | .await | |
| 572 | 579 | .map_err(|e| ApiError::internal(e.to_string()))?; | |
| 573 | 580 |
| @@ -327,6 +327,7 @@ async fn apply_delete_exec( | |||
| 327 | 327 | mod tests { | |
| 328 | 328 | use super::super::tests::*; | |
| 329 | 329 | use super::*; | |
| 330 | + | use synckit_client::Hlc; | |
| 330 | 331 | use serde_json::json; | |
| 331 | 332 | use uuid::Uuid; | |
| 332 | 333 | ||
| @@ -345,7 +346,8 @@ mod tests { | |||
| 345 | 346 | op: ChangeOp::Insert, | |
| 346 | 347 | row_id: format!("{}:ordered", feed_id), | |
| 347 | 348 | timestamp: chrono::Utc::now(), | |
| 348 | - | hlc: Default::default(), | |
| 349 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 350 | + | extra: Default::default(), | |
| 349 | 351 | data: Some(json!({"feed_id": feed_id, "tag": "ordered"})), | |
| 350 | 352 | }, | |
| 351 | 353 | ChangeEntry { | |
| @@ -353,7 +355,8 @@ mod tests { | |||
| 353 | 355 | op: ChangeOp::Insert, | |
| 354 | 356 | row_id: feed_id.clone(), | |
| 355 | 357 | timestamp: chrono::Utc::now(), | |
| 356 | - | hlc: Default::default(), | |
| 358 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 359 | + | extra: Default::default(), | |
| 357 | 360 | data: Some(json!({ | |
| 358 | 361 | "id": feed_id, | |
| 359 | 362 | "busser_id": "rss", | |
| @@ -414,7 +417,8 @@ mod tests { | |||
| 414 | 417 | op: ChangeOp::Delete, | |
| 415 | 418 | row_id: feed_id.clone(), | |
| 416 | 419 | timestamp: chrono::Utc::now(), | |
| 417 | - | hlc: Default::default(), | |
| 420 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 421 | + | extra: Default::default(), | |
| 418 | 422 | data: None, | |
| 419 | 423 | }, | |
| 420 | 424 | ChangeEntry { | |
| @@ -422,7 +426,8 @@ mod tests { | |||
| 422 | 426 | op: ChangeOp::Delete, | |
| 423 | 427 | row_id: format!("{}:doomed", feed_id), | |
| 424 | 428 | timestamp: chrono::Utc::now(), | |
| 425 | - | hlc: Default::default(), | |
| 429 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 430 | + | extra: Default::default(), | |
| 426 | 431 | data: None, | |
| 427 | 432 | }, | |
| 428 | 433 | ]; | |
| @@ -460,7 +465,8 @@ mod tests { | |||
| 460 | 465 | op: ChangeOp::Insert, | |
| 461 | 466 | row_id: feed_id.clone(), | |
| 462 | 467 | timestamp: chrono::Utc::now(), | |
| 463 | - | hlc: Default::default(), | |
| 468 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 469 | + | extra: Default::default(), | |
| 464 | 470 | data: Some(json!({ | |
| 465 | 471 | "id": feed_id, | |
| 466 | 472 | "busser_id": "rss", | |
| @@ -518,7 +524,8 @@ mod tests { | |||
| 518 | 524 | op: ChangeOp::Insert, | |
| 519 | 525 | row_id: new_feed_id.clone(), | |
| 520 | 526 | timestamp: chrono::Utc::now(), | |
| 521 | - | hlc: Default::default(), | |
| 527 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 528 | + | extra: Default::default(), | |
| 522 | 529 | data: Some(json!({ | |
| 523 | 530 | "id": new_feed_id, | |
| 524 | 531 | "busser_id": "hn", | |
| @@ -540,7 +547,8 @@ mod tests { | |||
| 540 | 547 | op: ChangeOp::Update, | |
| 541 | 548 | row_id: item_id.clone(), | |
| 542 | 549 | timestamp: chrono::Utc::now(), | |
| 543 | - | hlc: Default::default(), | |
| 550 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 551 | + | extra: Default::default(), | |
| 544 | 552 | data: Some(json!({ | |
| 545 | 553 | "id": item_id, | |
| 546 | 554 | "is_read": 1, | |
| @@ -553,7 +561,8 @@ mod tests { | |||
| 553 | 561 | op: ChangeOp::Delete, | |
| 554 | 562 | row_id: existing_feed_id.clone(), | |
| 555 | 563 | timestamp: chrono::Utc::now(), | |
| 556 | - | hlc: Default::default(), | |
| 564 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 565 | + | extra: Default::default(), | |
| 557 | 566 | data: None, | |
| 558 | 567 | }, | |
| 559 | 568 | ]; |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use chrono::Utc; | |
| 4 | 4 | use sqlx::SqlitePool; | |
| 5 | - | use synckit_client::{ChangeEntry, ChangeOp, DeviceId, SyncKitClient}; | |
| 5 | + | use synckit_client::{ChangeEntry, ChangeOp, DeviceId, Hlc, SyncKitClient}; | |
| 6 | 6 | use tracing::{debug, instrument, warn}; | |
| 7 | 7 | use uuid::Uuid; | |
| 8 | 8 | ||
| @@ -68,8 +68,11 @@ pub async fn push_changes( | |||
| 68 | 68 | op: change_op, | |
| 69 | 69 | row_id, | |
| 70 | 70 | timestamp: ts, | |
| 71 | - | hlc: Default::default(), | |
| 71 | + | // BB does not do client-side conflict resolution, so this HLC is | |
| 72 | + | // never read; the nil-node floor is the sanctioned placeholder. | |
| 73 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 72 | 74 | data: json_data, | |
| 75 | + | extra: Default::default(), | |
| 73 | 76 | }) | |
| 74 | 77 | }) | |
| 75 | 78 | .collect(); |