| 25 |
25 |
|
use super::schema::{SyncMode, SyncSchema};
|
| 26 |
26 |
|
use crate::client::SyncKitClient;
|
| 27 |
27 |
|
use crate::error::{Result, SyncKitError};
|
| 28 |
|
- |
use crate::ids::DeviceId;
|
|
28 |
+ |
use crate::ids::{DeviceId, GroupId};
|
| 29 |
29 |
|
use crate::types::{ChangeEntry, ChangeOp, Device, Hlc, PulledChange};
|
| 30 |
30 |
|
|
|
31 |
+ |
/// A sync scope: the personal changelog, or one group's shared changelog. Each
|
|
32 |
+ |
/// scope has its own changelog partition (`sync_changelog.scope`), pull cursor
|
|
33 |
+ |
/// (`sync_scope_cursor`), key, and endpoint. See the wiki design note
|
|
34 |
+ |
/// `synckit-multiscope-design`.
|
|
35 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
36 |
+ |
pub enum SyncScope {
|
|
37 |
+ |
/// The user's personal data, encrypted under the master key.
|
|
38 |
+ |
Personal,
|
|
39 |
+ |
/// A group's shared data, encrypted under the group's GCK at `gck_version`.
|
|
40 |
+ |
Group { id: GroupId, gck_version: i32 },
|
|
41 |
+ |
}
|
|
42 |
+ |
|
|
43 |
+ |
impl SyncScope {
|
|
44 |
+ |
/// The `sync_changelog.scope` / `sync_scope_cursor.scope` string for this
|
|
45 |
+ |
/// scope: `""` for personal, the group id otherwise.
|
|
46 |
+ |
pub fn key(&self) -> String {
|
|
47 |
+ |
match self {
|
|
48 |
+ |
SyncScope::Personal => String::new(),
|
|
49 |
+ |
SyncScope::Group { id, .. } => id.to_string(),
|
|
50 |
+ |
}
|
|
51 |
+ |
}
|
|
52 |
+ |
}
|
|
53 |
+ |
|
| 31 |
54 |
|
/// Maximum changes sent in one push batch.
|
| 32 |
55 |
|
pub const PUSH_BATCH_LIMIT: usize = 500;
|
| 33 |
56 |
|
|
| 58 |
81 |
|
device_id: DeviceId,
|
| 59 |
82 |
|
cursor: i64,
|
| 60 |
83 |
|
) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send;
|
|
84 |
+ |
|
|
85 |
+ |
/// The groups this user belongs to, as `(group_id, current_gck_version)` —
|
|
86 |
+ |
/// the scopes to sync beyond personal. Defaults to none, so a transport that
|
|
87 |
+ |
/// does not support groups (a test double, a future minimal SDK) simply never
|
|
88 |
+ |
/// syncs any group scope.
|
|
89 |
+ |
// `-> impl Future + Send` (not `async fn`) keeps the explicit Send bound the
|
|
90 |
+ |
// store's spawned tasks need; that is the whole trait's convention.
|
|
91 |
+ |
#[allow(clippy::manual_async_fn)]
|
|
92 |
+ |
fn list_group_scopes(&self) -> impl Future<Output = Result<Vec<(GroupId, i32)>>> + Send {
|
|
93 |
+ |
async move { Ok(Vec::new()) }
|
|
94 |
+ |
}
|
|
95 |
+ |
|
|
96 |
+ |
/// Push encrypted changes to a group's shared changelog. The implementation
|
|
97 |
+ |
/// resolves the group's GCK internally; the caller never handles keys. The
|
|
98 |
+ |
/// default errors — it is only reached if [`list_group_scopes`] returns a
|
|
99 |
+ |
/// group, which the default never does.
|
|
100 |
+ |
#[allow(clippy::manual_async_fn)]
|
|
101 |
+ |
fn group_scope_push(
|
|
102 |
+ |
&self,
|
|
103 |
+ |
_group_id: GroupId,
|
|
104 |
+ |
_gck_version: i32,
|
|
105 |
+ |
_device_id: DeviceId,
|
|
106 |
+ |
_changes: Vec<ChangeEntry>,
|
|
107 |
+ |
) -> impl Future<Output = Result<i64>> + Send {
|
|
108 |
+ |
async move {
|
|
109 |
+ |
Err(SyncKitError::Internal(
|
|
110 |
+ |
"group sync not supported by this transport".into(),
|
|
111 |
+ |
))
|
|
112 |
+ |
}
|
|
113 |
+ |
}
|
|
114 |
+ |
|
|
115 |
+ |
/// Pull a group's changes since `cursor`, decrypted under its GCK. Default
|
|
116 |
+ |
/// errors; see [`group_scope_push`](Self::group_scope_push).
|
|
117 |
+ |
#[allow(clippy::manual_async_fn)]
|
|
118 |
+ |
fn group_scope_pull(
|
|
119 |
+ |
&self,
|
|
120 |
+ |
_group_id: GroupId,
|
|
121 |
+ |
_gck_version: i32,
|
|
122 |
+ |
_device_id: DeviceId,
|
|
123 |
+ |
_cursor: i64,
|
|
124 |
+ |
) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
|
|
125 |
+ |
async move {
|
|
126 |
+ |
Err(SyncKitError::Internal(
|
|
127 |
+ |
"group sync not supported by this transport".into(),
|
|
128 |
+ |
))
|
|
129 |
+ |
}
|
|
130 |
+ |
}
|
| 61 |
131 |
|
}
|
| 62 |
132 |
|
|
|
133 |
+ |
// The group methods below return `-> impl Future + Send` with an `async move`
|
|
134 |
+ |
// body (not `async fn`) to keep the explicit Send bound the trait requires.
|
|
135 |
+ |
#[allow(clippy::manual_async_fn)]
|
| 63 |
136 |
|
impl SyncTransport for SyncKitClient {
|
| 64 |
137 |
|
fn register_device(
|
| 65 |
138 |
|
&self,
|
| 85 |
158 |
|
) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
|
| 86 |
159 |
|
SyncKitClient::pull_rich(self, device_id, cursor)
|
| 87 |
160 |
|
}
|
|
161 |
+ |
|
|
162 |
+ |
fn list_group_scopes(&self) -> impl Future<Output = Result<Vec<(GroupId, i32)>>> + Send {
|
|
163 |
+ |
async move {
|
|
164 |
+ |
Ok(self
|
|
165 |
+ |
.list_groups()
|
|
166 |
+ |
.await?
|
|
167 |
+ |
.into_iter()
|
|
168 |
+ |
.map(|g| (g.id, g.gck_version))
|
|
169 |
+ |
.collect())
|
|
170 |
+ |
}
|
|
171 |
+ |
}
|
|
172 |
+ |
|
|
173 |
+ |
fn group_scope_push(
|
|
174 |
+ |
&self,
|
|
175 |
+ |
group_id: GroupId,
|
|
176 |
+ |
gck_version: i32,
|
|
177 |
+ |
device_id: DeviceId,
|
|
178 |
+ |
changes: Vec<ChangeEntry>,
|
|
179 |
+ |
) -> impl Future<Output = Result<i64>> + Send {
|
|
180 |
+ |
async move {
|
|
181 |
+ |
let gck = self.group_content_key(group_id, gck_version).await?;
|
|
182 |
+ |
self.group_push(group_id, &gck, device_id, changes).await
|
|
183 |
+ |
}
|
|
184 |
+ |
}
|
|
185 |
+ |
|
|
186 |
+ |
fn group_scope_pull(
|
|
187 |
+ |
&self,
|
|
188 |
+ |
group_id: GroupId,
|
|
189 |
+ |
gck_version: i32,
|
|
190 |
+ |
device_id: DeviceId,
|
|
191 |
+ |
cursor: i64,
|
|
192 |
+ |
) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
|
|
193 |
+ |
async move {
|
|
194 |
+ |
let gck = self.group_content_key(group_id, gck_version).await?;
|
|
195 |
+ |
match self
|
|
196 |
+ |
.group_pull_rich(group_id, &gck, device_id, cursor)
|
|
197 |
+ |
.await
|
|
198 |
+ |
{
|
|
199 |
+ |
// A decrypt failure means this client is holding a stale GCK (it
|
|
200 |
+ |
// missed a rotation): drop it, re-fetch the current grant, retry
|
|
201 |
+ |
// once. A second failure surfaces.
|
|
202 |
+ |
Err(SyncKitError::DecryptionFailed) => {
|
|
203 |
+ |
self.invalidate_gck(group_id);
|
|
204 |
+ |
let gck = self.group_content_key(group_id, gck_version).await?;
|
|
205 |
+ |
self.group_pull_rich(group_id, &gck, device_id, cursor)
|
|
206 |
+ |
.await
|
|
207 |
+ |
}
|
|
208 |
+ |
other => other,
|
|
209 |
+ |
}
|
|
210 |
+ |
}
|
|
211 |
+ |
}
|
| 88 |
212 |
|
}
|
| 89 |
213 |
|
|
| 90 |
214 |
|
/// Ensure this install has a registered device, returning its id (which doubles
|
| 135 |
259 |
|
total_read: usize,
|
| 136 |
260 |
|
}
|
| 137 |
261 |
|
|
| 138 |
|
- |
/// Drain all unpushed local changes to the server, batch by batch.
|
|
262 |
+ |
/// Drain all unpushed local changes for the **personal** scope. Thin wrapper over
|
|
263 |
+ |
/// [`push_scope`] preserved for the personal call sites.
|
|
264 |
+ |
pub async fn push_changes<T: SyncTransport>(
|
|
265 |
+ |
db: &DbSource,
|
|
266 |
+ |
client: &T,
|
|
267 |
+ |
device_id: DeviceId,
|
|
268 |
+ |
) -> Result<u64> {
|
|
269 |
+ |
push_scope(db, client, device_id, SyncScope::Personal).await
|
|
270 |
+ |
}
|
|
271 |
+ |
|
|
272 |
+ |
/// Drain all unpushed local changes for one `scope` to the server, batch by batch.
|
| 139 |
273 |
|
///
|
| 140 |
|
- |
/// Each batch is stamped and read on a blocking thread, sent, then marked pushed
|
|
274 |
+ |
/// Each batch is stamped and read on a blocking thread, sent to the scope's
|
|
275 |
+ |
/// endpoint (personal push, or a group push under its GCK), then marked pushed
|
| 141 |
276 |
|
/// with its HLC recorded as the committed clock (so a peer's later stale re-pull
|
| 142 |
|
- |
/// of this row is gated out). `device_id` doubles as the HLC node.
|
| 143 |
|
- |
pub async fn push_changes<T: SyncTransport>(
|
|
277 |
+ |
/// of this row is gated out). `device_id` doubles as the HLC node. The drain
|
|
278 |
+ |
/// reads only rows whose `sync_changelog.scope` matches, so a group's changes
|
|
279 |
+ |
/// never leak into the personal push and vice versa.
|
|
280 |
+ |
pub async fn push_scope<T: SyncTransport>(
|
| 144 |
281 |
|
db: &DbSource,
|
| 145 |
282 |
|
client: &T,
|
| 146 |
283 |
|
device_id: DeviceId,
|
|
284 |
+ |
scope: SyncScope,
|
| 147 |
285 |
|
) -> Result<u64> {
|
|
286 |
+ |
let scope_key = scope.key();
|
| 148 |
287 |
|
let mut pushed_total = 0u64;
|
| 149 |
288 |
|
loop {
|
| 150 |
289 |
|
let db_read = db.clone();
|
|
290 |
+ |
let sk = scope_key.clone();
|
| 151 |
291 |
|
let batch =
|
| 152 |
|
- |
tokio::task::spawn_blocking(move || read_push_batch(&db_read.open()?, device_id))
|
|
292 |
+ |
tokio::task::spawn_blocking(move || read_push_batch(&db_read.open()?, device_id, &sk))
|
| 153 |
293 |
|
.await
|
| 154 |
294 |
|
.map_err(join_err)??;
|
| 155 |
295 |
|
|
| 158 |
298 |
|
}
|
| 159 |
299 |
|
|
| 160 |
300 |
|
if !batch.wire.is_empty() {
|
| 161 |
|
- |
client.push(device_id, batch.wire.clone()).await?;
|
|
301 |
+ |
match scope {
|
|
302 |
+ |
SyncScope::Personal => {
|
|
303 |
+ |
client.push(device_id, batch.wire.clone()).await?;
|
|
304 |
+ |
}
|
|
305 |
+ |
SyncScope::Group { id, gck_version } => {
|
|
306 |
+ |
client
|
|
307 |
+ |
.group_scope_push(id, gck_version, device_id, batch.wire.clone())
|
|
308 |
+ |
.await?;
|
|
309 |
+ |
}
|
|
310 |
+ |
}
|
| 162 |
311 |
|
}
|
| 163 |
312 |
|
|
| 164 |
313 |
|
let db_write = db.clone();
|
| 183 |
332 |
|
Ok(pushed_total)
|
| 184 |
333 |
|
}
|
| 185 |
334 |
|
|
| 186 |
|
- |
fn read_push_batch(conn: &Connection, device_id: DeviceId) -> Result<PushBatch> {
|
|
335 |
+ |
fn read_push_batch(conn: &Connection, device_id: DeviceId, scope: &str) -> Result<PushBatch> {
|
| 187 |
336 |
|
stamp_pending(conn, device_id, Utc::now().timestamp_millis())?;
|
| 188 |
337 |
|
let mut stmt = conn.prepare(
|
| 189 |
338 |
|
"SELECT id, table_name, op, row_id, data, hlc_wall, hlc_counter \
|
| 190 |
|
- |
FROM sync_changelog WHERE pushed = 0 ORDER BY id LIMIT ?1",
|
|
339 |
+ |
FROM sync_changelog WHERE pushed = 0 AND scope = ?2 ORDER BY id LIMIT ?1",
|
| 191 |
340 |
|
)?;
|
| 192 |
|
- |
let rows = stmt.query_map([PUSH_BATCH_LIMIT as i64], |r| {
|
|
341 |
+ |
let rows = stmt.query_map(rusqlite::params![PUSH_BATCH_LIMIT as i64, scope], |r| {
|
| 193 |
342 |
|
Ok((
|
| 194 |
343 |
|
r.get::<_, i64>(0)?,
|
| 195 |
344 |
|
r.get::<_, String>(1)?,
|
| 400 |
549 |
|
use super::*;
|
| 401 |
550 |
|
use std::sync::{Arc, Mutex};
|
| 402 |
551 |
|
|
| 403 |
|
- |
/// A shared in-memory "server": an append-only log of (origin device, entry).
|
|
552 |
+ |
/// A shared in-memory "server": an append-only personal log of (origin
|
|
553 |
+ |
/// device, entry), plus a group log of (group, origin device, entry).
|
| 404 |
554 |
|
#[derive(Clone, Default)]
|
| 405 |
555 |
|
struct FakeServer {
|
| 406 |
556 |
|
log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>,
|
|
557 |
+ |
group_log: Arc<Mutex<Vec<(GroupId, DeviceId, ChangeEntry)>>>,
|
| 407 |
558 |
|
}
|
| 408 |
559 |
|
|
| 409 |
560 |
|
impl SyncTransport for FakeServer {
|
|
561 |
+ |
fn group_scope_push(
|
|
562 |
+ |
&self,
|
|
563 |
+ |
group_id: GroupId,
|
|
564 |
+ |
_gck_version: i32,
|
|
565 |
+ |
device_id: DeviceId,
|
|
566 |
+ |
changes: Vec<ChangeEntry>,
|
|
567 |
+ |
) -> impl Future<Output = Result<i64>> + Send {
|
|
568 |
+ |
let group_log = self.group_log.clone();
|
|
569 |
+ |
async move {
|
|
570 |
+ |
let mut l = group_log.lock().unwrap();
|
|
571 |
+ |
for c in changes {
|
|
572 |
+ |
l.push((group_id, device_id, c));
|
|
573 |
+ |
}
|
|
574 |
+ |
Ok(l.len() as i64)
|
|
575 |
+ |
}
|
|
576 |
+ |
}
|
|
577 |
+ |
|
| 410 |
578 |
|
async fn register_device(&self, _name: &str, _platform: &str) -> Result<Device> {
|
| 411 |
579 |
|
Ok(Device {
|
| 412 |
580 |
|
id: DeviceId::new(uuid::Uuid::from_u128(0xDE)),
|
| 461 |
629 |
|
SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
|
| 462 |
630 |
|
}
|
| 463 |
631 |
|
|
|
632 |
+ |
/// A schema with a group-scoped table: `task` carries a local `group_id`
|
|
633 |
+ |
/// provenance column (not a synced column), declared via `group_scoped`.
|
|
634 |
+ |
fn group_schema() -> SyncSchema {
|
|
635 |
+ |
SyncSchema::new(vec![
|
|
636 |
+ |
SyncTable::full("task", &["id", "name"]).group_scoped("group_id"),
|
|
637 |
+ |
])
|
|
638 |
+ |
}
|
|
639 |
+ |
|
|
640 |
+ |
fn group_device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
|
|
641 |
+ |
let db = DbSource::path(path);
|
|
642 |
+ |
let conn = db.open().unwrap();
|
|
643 |
+ |
conn.execute_batch("CREATE TABLE task (id TEXT PRIMARY KEY, name TEXT, group_id TEXT);")
|
|
644 |
+ |
.unwrap();
|
|
645 |
+ |
conn.execute_batch(&group_schema().migration_sql()).unwrap();
|
|
646 |
+ |
(db, DeviceId::new(uuid::Uuid::from_u128(n)))
|
|
647 |
+ |
}
|
|
648 |
+ |
|
|
649 |
+ |
#[tokio::test]
|
|
650 |
+ |
async fn push_scope_drains_only_its_own_scope() {
|
|
651 |
+ |
let dir = tempdir();
|
|
652 |
+ |
let (db, node) = group_device(&dir.join("g.db"), 7);
|
|
653 |
+ |
let server = FakeServer::default();
|
|
654 |
+ |
let gid = GroupId::new(uuid::Uuid::from_u128(0x6971));
|
|
655 |
+ |
|
|
656 |
+ |
// A personal task (group_id NULL) and a group task (group_id = gid).
|
|
657 |
+ |
{
|
|
658 |
+ |
let c = db.open().unwrap();
|
|
659 |
+ |
c.execute(
|
|
660 |
+ |
"INSERT INTO task (id, name, group_id) VALUES ('p', 'personal', NULL)",
|
|
661 |
+ |
[],
|
|
662 |
+ |
)
|
|
663 |
+ |
.unwrap();
|
|
664 |
+ |
c.execute(
|
|
665 |
+ |
"INSERT INTO task (id, name, group_id) VALUES ('g', 'grouped', ?1)",
|
|
666 |
+ |
[gid.to_string()],
|
|
667 |
+ |
)
|
|
668 |
+ |
.unwrap();
|
|
669 |
+ |
}
|
|
670 |
+ |
|
|
671 |
+ |
// Personal push drains only the personal row.
|
|
672 |
+ |
let pushed = push_scope(&db, &server, node, SyncScope::Personal)
|
|
673 |
+ |
.await
|
|
674 |
+ |
.unwrap();
|
|
675 |
+ |
assert_eq!(pushed, 1);
|
|
676 |
+ |
assert_eq!(server.log.lock().unwrap().len(), 1);
|
|
677 |
+ |
assert_eq!(server.log.lock().unwrap()[0].1.row_id, "p");
|
|
678 |
+ |
assert!(server.group_log.lock().unwrap().is_empty());
|
|
679 |
+ |
|
|
680 |
+ |
// Group push drains only the group row, to that group.
|
|
681 |
+ |
let pushed = push_scope(
|
|
682 |
+ |
&db,
|
|
683 |
+ |
&server,
|
|
684 |
+ |
node,
|
|
685 |
+ |
SyncScope::Group {
|
|
686 |
+ |
id: gid,
|
|
687 |
+ |
gck_version: 1,
|
|
688 |
+ |
},
|
|
689 |
+ |
)
|
|
690 |
+ |
.await
|
|
691 |
+ |
.unwrap();
|
|
692 |
+ |
assert_eq!(pushed, 1);
|
|
693 |
+ |
let gl = server.group_log.lock().unwrap();
|
|
694 |
+ |
assert_eq!(gl.len(), 1);
|
|
695 |
+ |
assert_eq!(gl[0].0, gid);
|
|
696 |
+ |
assert_eq!(gl[0].2.row_id, "g");
|
|
697 |
+ |
// The personal log did not grow.
|
|
698 |
+ |
assert_eq!(server.log.lock().unwrap().len(), 1);
|
|
699 |
+ |
}
|
|
700 |
+ |
|
| 464 |
701 |
|
fn device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
|
| 465 |
702 |
|
let db = DbSource::path(path);
|
| 466 |
703 |
|
let conn = db.open().unwrap();
|