max / makenotwork
5 files changed,
+242 insertions,
-35 deletions
| @@ -51,6 +51,7 @@ pub fn apply_remote_changes( | |||
| 51 | 51 | conn: &mut Connection, | |
| 52 | 52 | schema: &SyncSchema, | |
| 53 | 53 | changes: &[ChangeEntry], | |
| 54 | + | scope: &str, | |
| 54 | 55 | ) -> Result<ApplyOutcome> { | |
| 55 | 56 | let by_name: HashMap<&str, &SyncTable> = schema.tables.iter().map(|t| (t.name, t)).collect(); | |
| 56 | 57 | ||
| @@ -63,7 +64,8 @@ pub fn apply_remote_changes( | |||
| 63 | 64 | conn.execute_batch("PRAGMA foreign_keys=OFF")?; | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 66 | - | let outcome = with_applying_remote(conn, |tx| apply_inner(tx, schema, &by_name, changes)); | |
| 67 | + | let outcome = | |
| 68 | + | with_applying_remote(conn, |tx| apply_inner(tx, schema, &by_name, changes, scope)); | |
| 67 | 69 | ||
| 68 | 70 | if fk_off { | |
| 69 | 71 | // Restore enforcement regardless of the apply result. A failed restore is | |
| @@ -82,6 +84,7 @@ fn apply_inner( | |||
| 82 | 84 | schema: &SyncSchema, | |
| 83 | 85 | by_name: &HashMap<&str, &SyncTable>, | |
| 84 | 86 | changes: &[ChangeEntry], | |
| 87 | + | scope: &str, | |
| 85 | 88 | ) -> Result<ApplyOutcome> { | |
| 86 | 89 | let mut out = ApplyOutcome::default(); | |
| 87 | 90 | ||
| @@ -109,7 +112,11 @@ fn apply_inner( | |||
| 109 | 112 | for change in changes.iter().filter(|c| { | |
| 110 | 113 | c.table == table.name && matches!(c.op, ChangeOp::Insert | ChangeOp::Update) | |
| 111 | 114 | }) { | |
| 112 | - | step(&mut out, table, apply_upsert(tx, table, change, ¬_null))?; | |
| 115 | + | step( | |
| 116 | + | &mut out, | |
| 117 | + | table, | |
| 118 | + | apply_upsert(tx, table, change, ¬_null, scope), | |
| 119 | + | )?; | |
| 113 | 120 | } | |
| 114 | 121 | } | |
| 115 | 122 | ||
| @@ -209,6 +216,7 @@ fn apply_upsert( | |||
| 209 | 216 | table: &SyncTable, | |
| 210 | 217 | change: &ChangeEntry, | |
| 211 | 218 | not_null: &HashMap<&str, HashSet<String>>, | |
| 219 | + | scope: &str, | |
| 212 | 220 | ) -> rusqlite::Result<bool> { | |
| 213 | 221 | let Some(data) = change.data.as_ref().filter(|v| v.is_object()) else { | |
| 214 | 222 | return Ok(false); // an upsert with no object payload cannot be applied | |
| @@ -222,7 +230,7 @@ fn apply_upsert( | |||
| 222 | 230 | let nn = not_null | |
| 223 | 231 | .get(table.name) | |
| 224 | 232 | .expect("Full table not_null precomputed"); | |
| 225 | - | apply_full_upsert(tx, table, data, nn) | |
| 233 | + | apply_full_upsert(tx, table, data, nn, scope) | |
| 226 | 234 | } | |
| 227 | 235 | } | |
| 228 | 236 | } | |
| @@ -232,6 +240,7 @@ fn apply_full_upsert( | |||
| 232 | 240 | table: &SyncTable, | |
| 233 | 241 | data: &Value, | |
| 234 | 242 | not_null: &HashSet<String>, | |
| 243 | + | scope: &str, | |
| 235 | 244 | ) -> rusqlite::Result<bool> { | |
| 236 | 245 | // A remote null for a NOT NULL column is invalid input the changelog never | |
| 237 | 246 | // emits; omit such columns so a new row takes the schema default and an | |
| @@ -258,6 +267,16 @@ fn apply_full_upsert( | |||
| 258 | 267 | return Ok(false); | |
| 259 | 268 | } | |
| 260 | 269 | ||
| 270 | + | // Group provenance: stamp a group-scoped table's `group_id` column from the | |
| 271 | + | // pull scope (never from the payload) so the pulled row is tagged with the | |
| 272 | + | // group it came from. Personal scope ("") leaves group_id at its default | |
| 273 | + | // (NULL). Injected into the INSERT columns only — never into the ON CONFLICT | |
| 274 | + | // SET, so an existing row's group never changes. | |
| 275 | + | let group_stamp: Option<&str> = table.group_scope.filter(|_| !scope.is_empty()); | |
| 276 | + | if let Some(gcol) = group_stamp { | |
| 277 | + | insert_cols.push(gcol); | |
| 278 | + | } | |
| 279 | + | ||
| 261 | 280 | let pk: HashSet<&str> = table.pk.iter().copied().collect(); | |
| 262 | 281 | let preserve: HashSet<&str> = table.preserve_local.iter().copied().collect(); | |
| 263 | 282 | // ON CONFLICT update touches present columns that are neither PK nor a | |
| @@ -307,6 +326,9 @@ fn apply_full_upsert( | |||
| 307 | 326 | .iter() | |
| 308 | 327 | .map(|v| Box::new((*v).to_string()) as Box<dyn ToSql>), | |
| 309 | 328 | ); | |
| 329 | + | if group_stamp.is_some() { | |
| 330 | + | params.push(Box::new(scope.to_string()) as Box<dyn ToSql>); | |
| 331 | + | } | |
| 310 | 332 | ||
| 311 | 333 | exec(tx, &sql, ¶ms)?; | |
| 312 | 334 | Ok(true) | |
| @@ -570,7 +592,7 @@ mod tests { | |||
| 570 | 592 | } | |
| 571 | 593 | ||
| 572 | 594 | fn apply(conn: &mut Connection, changes: &[ChangeEntry]) -> ApplyOutcome { | |
| 573 | - | apply_remote_changes(conn, &schema(), changes).unwrap() | |
| 595 | + | apply_remote_changes(conn, &schema(), changes, "").unwrap() | |
| 574 | 596 | } | |
| 575 | 597 | ||
| 576 | 598 | #[test] |
| @@ -19,8 +19,9 @@ use super::scheduler::{ | |||
| 19 | 19 | }; | |
| 20 | 20 | use super::schema::SyncSchema; | |
| 21 | 21 | use super::sync::{ | |
| 22 | - | SyncTransport, cleanup_changelog, create_initial_snapshot, enforce_changelog_retention, | |
| 23 | - | ensure_device_registered, pull_changes, push_changes, | |
| 22 | + | SyncScope, SyncTransport, cleanup_changelog, create_initial_snapshot, | |
| 23 | + | enforce_changelog_retention, ensure_device_registered, pull_changes, pull_scope, push_changes, | |
| 24 | + | push_scope, | |
| 24 | 25 | }; | |
| 25 | 26 | use crate::client::SyncKitClient; | |
| 26 | 27 | use crate::error::{Result, SyncKitError}; | |
| @@ -154,8 +155,22 @@ impl<C: SyncTransport + BlobTransport> SyncStore<C> { | |||
| 154 | 155 | .await?; | |
| 155 | 156 | self.maybe_snapshot().await?; | |
| 156 | 157 | ||
| 157 | - | let pushed = push_changes(&self.db, &*self.client, device_id).await?; | |
| 158 | + | let mut pushed = push_changes(&self.db, &*self.client, device_id).await?; | |
| 158 | 159 | let pull = pull_changes(&self.db, &*self.client, &self.schema, device_id).await?; | |
| 160 | + | let mut pulled = pull.applied; | |
| 161 | + | let mut changed_tables = pull.changed_tables; | |
| 162 | + | ||
| 163 | + | // Group scopes: sync each group the user belongs to, under its own key and | |
| 164 | + | // cursor. A transport without groups reports none (the default), so this | |
| 165 | + | // loop is a no-op for personal-only apps. | |
| 166 | + | for (id, gck_version) in self.client.list_group_scopes().await? { | |
| 167 | + | let scope = SyncScope::Group { id, gck_version }; | |
| 168 | + | pushed += push_scope(&self.db, &*self.client, device_id, scope).await?; | |
| 169 | + | let gp = pull_scope(&self.db, &*self.client, &self.schema, device_id, scope).await?; | |
| 170 | + | pulled += gp.applied; | |
| 171 | + | changed_tables.extend(gp.changed_tables); | |
| 172 | + | } | |
| 173 | + | ||
| 159 | 174 | let blobs = match &self.blob_policy { | |
| 160 | 175 | Some(policy) => sync_blobs(&self.db, &*self.client, policy).await?, | |
| 161 | 176 | None => BlobOutcome::default(), | |
| @@ -164,8 +179,8 @@ impl<C: SyncTransport + BlobTransport> SyncStore<C> { | |||
| 164 | 179 | self.finalize().await?; | |
| 165 | 180 | Ok(SyncOutcome { | |
| 166 | 181 | pushed, | |
| 167 | - | pulled: pull.applied, | |
| 168 | - | changed_tables: pull.changed_tables, | |
| 182 | + | pulled, | |
| 183 | + | changed_tables, | |
| 169 | 184 | blobs, | |
| 170 | 185 | }) | |
| 171 | 186 | } | |
| @@ -354,11 +369,18 @@ mod tests { | |||
| 354 | 369 | ||
| 355 | 370 | /// A combined fake implementing both transports over a shared changelog log | |
| 356 | 371 | /// and blob store, tagged with this device's registered id. | |
| 372 | + | type GroupLog = Arc<Mutex<Vec<(crate::ids::GroupId, DeviceId, ChangeEntry)>>>; | |
| 373 | + | ||
| 357 | 374 | #[derive(Clone)] | |
| 358 | 375 | struct Fake { | |
| 359 | 376 | device_id: DeviceId, | |
| 360 | 377 | log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>, | |
| 361 | 378 | blobs: Arc<Mutex<HashMap<String, Vec<u8>>>>, | |
| 379 | + | /// Groups this member belongs to, reported by `list_group_scopes`. | |
| 380 | + | groups: Vec<(crate::ids::GroupId, i32)>, | |
| 381 | + | /// Shared group changelog (plaintext — the fake bypasses encryption, which | |
| 382 | + | /// is covered by the `client::groups` tests). | |
| 383 | + | group_log: GroupLog, | |
| 362 | 384 | } | |
| 363 | 385 | impl Fake { | |
| 364 | 386 | fn new(device_id: u128, log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>) -> Self { | |
| @@ -366,8 +388,17 @@ mod tests { | |||
| 366 | 388 | device_id: DeviceId::new(uuid::Uuid::from_u128(device_id)), | |
| 367 | 389 | log, | |
| 368 | 390 | blobs: Arc::new(Mutex::new(HashMap::new())), | |
| 391 | + | groups: Vec::new(), | |
| 392 | + | group_log: Arc::new(Mutex::new(Vec::new())), | |
| 369 | 393 | } | |
| 370 | 394 | } | |
| 395 | + | ||
| 396 | + | /// Enroll this member in a group sharing `group_log`. | |
| 397 | + | fn in_group(mut self, id: crate::ids::GroupId, version: i32, group_log: GroupLog) -> Self { | |
| 398 | + | self.groups.push((id, version)); | |
| 399 | + | self.group_log = group_log; | |
| 400 | + | self | |
| 401 | + | } | |
| 371 | 402 | } | |
| 372 | 403 | ||
| 373 | 404 | impl SyncTransport for Fake { | |
| @@ -424,6 +455,56 @@ mod tests { | |||
| 424 | 455 | Ok((out, l.len() as i64, false)) | |
| 425 | 456 | } | |
| 426 | 457 | } | |
| 458 | + | ||
| 459 | + | fn list_group_scopes( | |
| 460 | + | &self, | |
| 461 | + | ) -> impl Future<Output = Result<Vec<(crate::ids::GroupId, i32)>>> + Send { | |
| 462 | + | let groups = self.groups.clone(); | |
| 463 | + | async move { Ok(groups) } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | fn group_scope_push( | |
| 467 | + | &self, | |
| 468 | + | group_id: crate::ids::GroupId, | |
| 469 | + | _gck_version: i32, | |
| 470 | + | device_id: DeviceId, | |
| 471 | + | changes: Vec<ChangeEntry>, | |
| 472 | + | ) -> impl Future<Output = Result<i64>> + Send { | |
| 473 | + | let group_log = self.group_log.clone(); | |
| 474 | + | async move { | |
| 475 | + | let mut l = group_log.lock().unwrap(); | |
| 476 | + | for c in changes { | |
| 477 | + | l.push((group_id, device_id, c)); | |
| 478 | + | } | |
| 479 | + | Ok(l.len() as i64) | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | fn group_scope_pull( | |
| 484 | + | &self, | |
| 485 | + | group_id: crate::ids::GroupId, | |
| 486 | + | _gck_version: i32, | |
| 487 | + | _device_id: DeviceId, | |
| 488 | + | cursor: i64, | |
| 489 | + | ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send { | |
| 490 | + | let group_log = self.group_log.clone(); | |
| 491 | + | async move { | |
| 492 | + | let l = group_log.lock().unwrap(); | |
| 493 | + | // Filter to this group, then page by global position (mirrors the | |
| 494 | + | // personal fake's seq scheme). | |
| 495 | + | let out: Vec<PulledChange> = l | |
| 496 | + | .iter() | |
| 497 | + | .enumerate() | |
| 498 | + | .filter(|(i, (gid, _, _))| *gid == group_id && (*i as i64 + 1) > cursor) | |
| 499 | + | .map(|(i, (_, dev, entry))| PulledChange { | |
| 500 | + | entry: entry.clone(), | |
| 501 | + | device_id: *dev, | |
| 502 | + | seq: i as i64 + 1, | |
| 503 | + | }) | |
| 504 | + | .collect(); | |
| 505 | + | Ok((out, l.len() as i64, false)) | |
| 506 | + | } | |
| 507 | + | } | |
| 427 | 508 | } | |
| 428 | 509 | ||
| 429 | 510 | impl BlobTransport for Fake { | |
| @@ -488,6 +569,84 @@ mod tests { | |||
| 488 | 569 | .build() | |
| 489 | 570 | } | |
| 490 | 571 | ||
| 572 | + | // ── Group-scope sync ── | |
| 573 | + | ||
| 574 | + | fn group_schema() -> SyncSchema { | |
| 575 | + | SyncSchema::new(vec![ | |
| 576 | + | SyncTable::full("task", &["id", "name"]).group_scoped("group_id"), | |
| 577 | + | ]) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | fn group_make_db(path: &std::path::Path) -> DbSource { | |
| 581 | + | let db = DbSource::path(path); | |
| 582 | + | let conn = db.open().unwrap(); | |
| 583 | + | conn.execute_batch("CREATE TABLE task (id TEXT PRIMARY KEY, name TEXT, group_id TEXT);") | |
| 584 | + | .unwrap(); | |
| 585 | + | conn.execute_batch(&group_schema().migration_sql()).unwrap(); | |
| 586 | + | db | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | fn group_store(db: DbSource, fake: Fake) -> SyncStore<Fake> { | |
| 590 | + | SyncStore::builder(db, fake, group_schema()) | |
| 591 | + | .device("dev", "test") | |
| 592 | + | .build() | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | #[tokio::test] | |
| 596 | + | async fn two_members_converge_through_a_group_scope() { | |
| 597 | + | let dir = tempdir(); | |
| 598 | + | let gid = crate::ids::GroupId::new(uuid::Uuid::from_u128(0x9)); | |
| 599 | + | let group_log = Arc::new(Mutex::new(Vec::new())); | |
| 600 | + | let plog = Arc::new(Mutex::new(Vec::new())); | |
| 601 | + | ||
| 602 | + | let da = group_make_db(&dir.join("a.db")); | |
| 603 | + | let dbb = group_make_db(&dir.join("b.db")); | |
| 604 | + | let sa = group_store( | |
| 605 | + | da.clone(), | |
| 606 | + | Fake::new(0xA, plog.clone()).in_group(gid, 1, group_log.clone()), | |
| 607 | + | ); | |
| 608 | + | let sb = group_store( | |
| 609 | + | dbb.clone(), | |
| 610 | + | Fake::new(0xB, plog.clone()).in_group(gid, 1, group_log.clone()), | |
| 611 | + | ); | |
| 612 | + | ||
| 613 | + | // Member A creates a task in the group. | |
| 614 | + | da.open() | |
| 615 | + | .unwrap() | |
| 616 | + | .execute( | |
| 617 | + | "INSERT INTO task (id, name, group_id) VALUES ('t', 'from-A', ?1)", | |
| 618 | + | [gid.to_string()], | |
| 619 | + | ) | |
| 620 | + | .unwrap(); | |
| 621 | + | ||
| 622 | + | sa.sync_now().await.unwrap(); // pushes the group task to the shared log | |
| 623 | + | sb.sync_now().await.unwrap(); // pulls it and applies, stamping group_id | |
| 624 | + | ||
| 625 | + | // Member B now has the task, tagged with the group it came from (stamped | |
| 626 | + | // from the channel, not the payload — the payload carried only id + name). | |
| 627 | + | let (name, group_id): (String, String) = dbb | |
| 628 | + | .open() | |
| 629 | + | .unwrap() | |
| 630 | + | .query_row("SELECT name, group_id FROM task WHERE id = 't'", [], |r| { | |
| 631 | + | Ok((r.get(0)?, r.get(1)?)) | |
| 632 | + | }) | |
| 633 | + | .unwrap(); | |
| 634 | + | assert_eq!(name, "from-A"); | |
| 635 | + | assert_eq!(group_id, gid.to_string()); | |
| 636 | + | ||
| 637 | + | // The group write never touched B's personal changelog scope. | |
| 638 | + | let personal_rows: i64 = dbb | |
| 639 | + | .open() | |
| 640 | + | .unwrap() | |
| 641 | + | .query_row( | |
| 642 | + | "SELECT COUNT(*) FROM sync_changelog WHERE scope = ''", | |
| 643 | + | [], | |
| 644 | + | |r| r.get(0), | |
| 645 | + | ) | |
| 646 | + | .unwrap(); | |
| 647 | + | assert_eq!(personal_rows, 0); | |
| 648 | + | } | |
| 649 | + | ||
| 491 | 650 | /// Pre-stamp a device's pending edit at a controlled time so cross-device HLC | |
| 492 | 651 | /// ordering is deterministic (sync_now's internal stamp is then a no-op). | |
| 493 | 652 | fn stamp_pending(db: &DbSource, node: DeviceId, now_ms: i64) { |
| @@ -373,7 +373,7 @@ mod tests { | |||
| 373 | 373 | ) { | |
| 374 | 374 | let now = Utc::now(); | |
| 375 | 375 | let resolved = resolve_pull(conn, s, node, pulled, now).unwrap(); | |
| 376 | - | apply_remote_changes(conn, s, &resolved).unwrap(); | |
| 376 | + | apply_remote_changes(conn, s, &resolved, "").unwrap(); | |
| 377 | 377 | record_committed(conn, &resolved).unwrap(); | |
| 378 | 378 | } | |
| 379 | 379 | ||
| @@ -500,7 +500,7 @@ mod tests { | |||
| 500 | 500 | let (src2, sn2) = device(3); | |
| 501 | 501 | let second = local_edit_as_pulled(&src2, sn2, "r", "second", 1, 2); // lower wall, later seq | |
| 502 | 502 | let resolved = resolve_pull(&a, &s, an, vec![first, second], Utc::now()).unwrap(); | |
| 503 | - | apply_remote_changes(&mut a, &s, &resolved).unwrap(); | |
| 503 | + | apply_remote_changes(&mut a, &s, &resolved, "").unwrap(); | |
| 504 | 504 | assert_eq!( | |
| 505 | 505 | note_name(&a, "r").as_deref(), | |
| 506 | 506 | Some("second"), |
| @@ -45,5 +45,5 @@ pub use schema::{ConflictStrategy, DeleteMode, RowIdScheme, SyncMode, SyncSchema | |||
| 45 | 45 | pub use sync::{ | |
| 46 | 46 | PUSH_BATCH_LIMIT, PullOutcome, SyncScope, SyncTransport, cleanup_changelog, | |
| 47 | 47 | create_initial_snapshot, enforce_changelog_retention, ensure_device_registered, pull_changes, | |
| 48 | - | push_changes, push_scope, | |
| 48 | + | pull_scope, push_changes, push_scope, | |
| 49 | 49 | }; |
| @@ -19,7 +19,9 @@ use chrono::Utc; | |||
| 19 | 19 | use rusqlite::Connection; | |
| 20 | 20 | ||
| 21 | 21 | use super::apply::{ApplyOutcome, apply_remote_changes}; | |
| 22 | - | use super::db::{DbSource, device_id, get_sync_state_or, set_device_id, set_sync_state}; | |
| 22 | + | use super::db::{ | |
| 23 | + | DbSource, device_id, get_scope_cursor, set_device_id, set_scope_cursor, set_sync_state, | |
| 24 | + | }; | |
| 23 | 25 | use super::hlc::{record_committed, resolve_pull, set_committed, stamp_pending}; | |
| 24 | 26 | use super::migrate::{json_object, row_id_expr}; | |
| 25 | 27 | use super::schema::{SyncMode, SyncSchema}; | |
| @@ -411,42 +413,62 @@ fn mark_pushed_committed( | |||
| 411 | 413 | ||
| 412 | 414 | // -- pull -- | |
| 413 | 415 | ||
| 414 | - | /// Pull, resolve, and apply all remote changes, page by page. | |
| 415 | - | /// | |
| 416 | - | /// Follows the SDK contract: the cursor is advanced only *after* the batch is | |
| 417 | - | /// applied and committed, in the same blocking step, so a crash re-pulls a batch | |
| 418 | - | /// the idempotent apply/gate absorbs. | |
| 416 | + | /// Pull, resolve, and apply all remote changes for the **personal** scope. Thin | |
| 417 | + | /// wrapper over [`pull_scope`] preserved for the personal call sites. | |
| 419 | 418 | pub async fn pull_changes<T: SyncTransport>( | |
| 420 | 419 | db: &DbSource, | |
| 421 | 420 | client: &T, | |
| 422 | 421 | schema: &SyncSchema, | |
| 423 | 422 | device_id: DeviceId, | |
| 424 | 423 | ) -> Result<PullOutcome> { | |
| 424 | + | pull_scope(db, client, schema, device_id, SyncScope::Personal).await | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | /// Pull, resolve, and apply all remote changes for one `scope`, page by page. | |
| 428 | + | /// | |
| 429 | + | /// Reads from the scope's own cursor (`sync_scope_cursor`), pulls from the | |
| 430 | + | /// scope's endpoint (personal pull, or a group pull under its GCK), and applies | |
| 431 | + | /// with the scope stamped so group rows are tagged with their group. Follows the | |
| 432 | + | /// SDK contract: the cursor advances only *after* the batch is applied and | |
| 433 | + | /// committed, in the same blocking step, so a crash re-pulls a batch the | |
| 434 | + | /// idempotent apply/gate absorbs. | |
| 435 | + | pub async fn pull_scope<T: SyncTransport>( | |
| 436 | + | db: &DbSource, | |
| 437 | + | client: &T, | |
| 438 | + | schema: &SyncSchema, | |
| 439 | + | device_id: DeviceId, | |
| 440 | + | scope: SyncScope, | |
| 441 | + | ) -> Result<PullOutcome> { | |
| 442 | + | let scope_key = scope.key(); | |
| 425 | 443 | let mut out = PullOutcome::default(); | |
| 426 | 444 | loop { | |
| 427 | 445 | let db_cursor = db.clone(); | |
| 428 | - | let cursor = tokio::task::spawn_blocking(move || -> Result<i64> { | |
| 429 | - | Ok(get_sync_state_or(&db_cursor.open()?, "pull_cursor", "0")? | |
| 430 | - | .parse() | |
| 431 | - | .unwrap_or(0)) | |
| 432 | - | }) | |
| 433 | - | .await | |
| 434 | - | .map_err(join_err)??; | |
| 446 | + | let sk = scope_key.clone(); | |
| 447 | + | let cursor = tokio::task::spawn_blocking(move || get_scope_cursor(&db_cursor.open()?, &sk)) | |
| 448 | + | .await | |
| 449 | + | .map_err(join_err)??; | |
| 435 | 450 | ||
| 436 | - | let (pulled, new_cursor, has_more) = client.pull_rich(device_id, cursor).await?; | |
| 451 | + | let (pulled, new_cursor, has_more) = match scope { | |
| 452 | + | SyncScope::Personal => client.pull_rich(device_id, cursor).await?, | |
| 453 | + | SyncScope::Group { id, gck_version } => { | |
| 454 | + | client | |
| 455 | + | .group_scope_pull(id, gck_version, device_id, cursor) | |
| 456 | + | .await? | |
| 457 | + | } | |
| 458 | + | }; | |
| 437 | 459 | ||
| 438 | 460 | if pulled.is_empty() { | |
| 439 | 461 | let db_c = db.clone(); | |
| 440 | - | tokio::task::spawn_blocking(move || { | |
| 441 | - | set_sync_state(&db_c.open()?, "pull_cursor", &new_cursor.to_string()) | |
| 442 | - | }) | |
| 443 | - | .await | |
| 444 | - | .map_err(join_err)??; | |
| 462 | + | let sk = scope_key.clone(); | |
| 463 | + | tokio::task::spawn_blocking(move || set_scope_cursor(&db_c.open()?, &sk, new_cursor)) | |
| 464 | + | .await | |
| 465 | + | .map_err(join_err)??; | |
| 445 | 466 | break; | |
| 446 | 467 | } | |
| 447 | 468 | ||
| 448 | 469 | let db_apply = db.clone(); | |
| 449 | 470 | let schema = schema.clone(); | |
| 471 | + | let sk = scope_key.clone(); | |
| 450 | 472 | let outcome = tokio::task::spawn_blocking(move || { | |
| 451 | 473 | apply_pull( | |
| 452 | 474 | &mut db_apply.open()?, | |
| @@ -454,6 +476,7 @@ pub async fn pull_changes<T: SyncTransport>( | |||
| 454 | 476 | device_id, | |
| 455 | 477 | pulled, | |
| 456 | 478 | new_cursor, | |
| 479 | + | &sk, | |
| 457 | 480 | ) | |
| 458 | 481 | }) | |
| 459 | 482 | .await | |
| @@ -474,11 +497,12 @@ fn apply_pull( | |||
| 474 | 497 | device_id: DeviceId, | |
| 475 | 498 | pulled: Vec<PulledChange>, | |
| 476 | 499 | new_cursor: i64, | |
| 500 | + | scope_key: &str, | |
| 477 | 501 | ) -> Result<ApplyOutcome> { | |
| 478 | 502 | let resolved = resolve_pull(conn, schema, device_id, pulled, Utc::now())?; | |
| 479 | - | let outcome = apply_remote_changes(conn, schema, &resolved)?; | |
| 503 | + | let outcome = apply_remote_changes(conn, schema, &resolved, scope_key)?; | |
| 480 | 504 | record_committed(conn, &resolved)?; | |
| 481 | - | set_sync_state(conn, "pull_cursor", &new_cursor.to_string())?; | |
| 505 | + | set_scope_cursor(conn, scope_key, new_cursor)?; | |
| 482 | 506 | Ok(outcome) | |
| 483 | 507 | } | |
| 484 | 508 | ||
| @@ -545,6 +569,7 @@ pub fn enforce_changelog_retention(conn: &Connection, cap: i64) -> Result<u64> { | |||
| 545 | 569 | ||
| 546 | 570 | #[cfg(test)] | |
| 547 | 571 | mod tests { | |
| 572 | + | use super::super::db::get_sync_state_or; | |
| 548 | 573 | use super::super::schema::SyncTable; | |
| 549 | 574 | use super::*; | |
| 550 | 575 | use std::sync::{Arc, Mutex}; | |
| @@ -777,8 +802,9 @@ mod tests { | |||
| 777 | 802 | let out = pull_changes(&db_, &server, &schema(), nb).await.unwrap(); | |
| 778 | 803 | assert_eq!(out.applied, 2); | |
| 779 | 804 | assert_eq!(note_name(&db_, "r1").as_deref(), Some("x")); | |
| 780 | - | let cursor = get_sync_state_or(&db_.open().unwrap(), "pull_cursor", "0").unwrap(); | |
| 781 | - | assert_eq!(cursor, "2"); | |
| 805 | + | // Personal pull advances the personal ('') scope cursor. | |
| 806 | + | let cursor = get_scope_cursor(&db_.open().unwrap(), "").unwrap(); | |
| 807 | + | assert_eq!(cursor, 2); | |
| 782 | 808 | } | |
| 783 | 809 | ||
| 784 | 810 | #[test] |