Skip to main content

max / balanced_breakfast

sync: migrate to synckit-client 0.6 pricing + ChangeEntry API BB no longer built against the current synckit-client: it referenced the removed fixed-tier pricing (TierInfo / get_available_tiers) and the pre-extra ChangeEntry shape. Mirror GO/AF's already-migrated call sites. - commands/sync.rs: sync_get_tiers returns AppPricing via get_app_pricing(); sync_subscribe uses create_subscription_checkout(cap_bytes, BillingInterval) with the 10 GiB floor cap (BB syncs metadata only, like GO). - sync_service/{upload,download}.rs: ChangeEntry gained a required `extra` field and Hlc lost Default; all constructions use Hlc::zero(DeviceId::nil()) (nil-node floor, never read — BB does no client-side conflict resolution) + extra: Default::default(). - settings-sync.js: pricing banner consumes AppPricing (min_charge_cents + annual_multiplier) instead of the old TierInfo array. - src-tauri [dev-dependencies]: enable synckit-client `testing` feature for set_master_key_raw in tests; resolver 2 keeps it out of release builds. cargo check --workspace --tests clean; 36 sync tests + 55 JS tests pass; release build clean. iOS target not re-verified (do on Mac before iOS build). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 17:17 UTC
Commit: 141cb64e6b8511d1dcfb81fd325124374a23f663
Parent: 26eec32
6 files changed, +111 insertions, -26 deletions
M Cargo.lock +64 -3
@@ -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();