#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use chrono::{DateTime, Utc};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sql_types::{BigInt, Bool, Nullable, Text};
use diesel::sqlite::SqliteConnection;
use serde::Serialize;
use serde::de::DeserializeOwned;
use super::SyncError;
use super::protocol::{Change, ChangeOutcome, Op, RemoteRow, Version};
const SCHEMA_DDL: &str = "
CREATE TABLE IF NOT EXISTS autumn_sync_rows (
collection TEXT NOT NULL,
pk TEXT NOT NULL,
payload TEXT,
server_version INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (collection, pk)
);
CREATE TABLE IF NOT EXISTS autumn_sync_pending (
change_id TEXT PRIMARY KEY,
collection TEXT NOT NULL,
pk TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('upsert', 'delete')),
payload TEXT,
base_version INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
queued_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS autumn_sync_pending_row
ON autumn_sync_pending (collection, pk);
CREATE TABLE IF NOT EXISTS autumn_sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
";
const STATE_DEVICE_ID: &str = "device_id";
const STATE_CURSOR: &str = "cursor";
const STATE_IDENTITY: &str = "identity";
fn store_err(err: impl std::fmt::Display) -> SyncError {
SyncError::Store(err.to_string())
}
#[derive(QueryableByName)]
struct RowRecord {
#[diesel(sql_type = Nullable<Text>)]
payload: Option<String>,
#[diesel(sql_type = Bool)]
deleted: bool,
}
#[derive(QueryableByName)]
struct VersionRecord {
#[diesel(sql_type = BigInt)]
server_version: i64,
}
#[derive(QueryableByName)]
struct ListRecord {
#[diesel(sql_type = Text)]
pk: String,
#[diesel(sql_type = Nullable<Text>)]
payload: Option<String>,
}
#[derive(QueryableByName)]
struct RowKeyRecord {
#[diesel(sql_type = Text)]
collection: String,
#[diesel(sql_type = Text)]
pk: String,
}
#[derive(QueryableByName)]
struct CountRecord {
#[diesel(sql_type = BigInt)]
count: i64,
}
#[derive(QueryableByName)]
struct StateRecord {
#[diesel(sql_type = Text)]
value: String,
}
#[derive(QueryableByName)]
struct PendingRecord {
#[diesel(sql_type = Text)]
change_id: String,
#[diesel(sql_type = Text)]
collection: String,
#[diesel(sql_type = Text)]
pk: String,
#[diesel(sql_type = Text)]
op: String,
#[diesel(sql_type = Nullable<Text>)]
payload: Option<String>,
#[diesel(sql_type = BigInt)]
base_version: i64,
#[diesel(sql_type = Text)]
updated_at: String,
}
fn parse_timestamp(value: &str) -> Result<DateTime<Utc>, SyncError> {
DateTime::parse_from_rfc3339(value)
.map(|dt| dt.with_timezone(&Utc))
.map_err(store_err)
}
fn get_state(conn: &mut SqliteConnection, key: &str) -> Result<Option<String>, SyncError> {
sql_query("SELECT value FROM autumn_sync_state WHERE key = ?")
.bind::<Text, _>(key)
.get_result::<StateRecord>(conn)
.optional()
.map(|record| record.map(|r| r.value))
.map_err(store_err)
}
fn set_state(
conn: &mut SqliteConnection,
key: &str,
value: &str,
) -> Result<(), diesel::result::Error> {
sql_query(
"INSERT INTO autumn_sync_state (key, value) VALUES (?, ?) \
ON CONFLICT (key) DO UPDATE SET value = excluded.value",
)
.bind::<Text, _>(key)
.bind::<Text, _>(value)
.execute(conn)
.map(|_| ())
}
fn base_version_for(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
) -> Result<Version, diesel::result::Error> {
let pending = sql_query(
"SELECT base_version AS server_version FROM autumn_sync_pending \
WHERE collection = ? AND pk = ?",
)
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.get_result::<VersionRecord>(conn)
.optional()?;
if let Some(record) = pending {
return Ok(record.server_version);
}
let row =
sql_query("SELECT server_version FROM autumn_sync_rows WHERE collection = ? AND pk = ?")
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.get_result::<VersionRecord>(conn)
.optional()?;
Ok(row.map_or(0, |record| record.server_version))
}
fn replace_pending(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
op: &str,
payload: Option<&str>,
base_version: Version,
updated_at: &str,
) -> Result<(), diesel::result::Error> {
sql_query("DELETE FROM autumn_sync_pending WHERE collection = ? AND pk = ?")
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.execute(conn)?;
sql_query(
"INSERT INTO autumn_sync_pending \
(change_id, collection, pk, op, payload, base_version, updated_at, queued_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind::<Text, _>(uuid::Uuid::new_v4().to_string())
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.bind::<Text, _>(op)
.bind::<Nullable<Text>, _>(payload)
.bind::<BigInt, _>(base_version)
.bind::<Text, _>(updated_at)
.bind::<Text, _>(Utc::now().to_rfc3339())
.execute(conn)
.map(|_| ())
}
fn upsert_row(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
payload: Option<&str>,
server_version: Version,
deleted: bool,
updated_at: &str,
) -> Result<(), diesel::result::Error> {
sql_query(
"INSERT INTO autumn_sync_rows \
(collection, pk, payload, server_version, deleted, updated_at) \
VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT (collection, pk) DO UPDATE SET \
payload = excluded.payload, server_version = excluded.server_version, \
deleted = excluded.deleted, updated_at = excluded.updated_at",
)
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.bind::<Nullable<Text>, _>(payload)
.bind::<BigInt, _>(server_version)
.bind::<Bool, _>(deleted)
.bind::<Text, _>(updated_at)
.execute(conn)
.map(|_| ())
}
fn upsert_remote_row(
conn: &mut SqliteConnection,
row: &RemoteRow,
) -> Result<(), diesel::result::Error> {
let payload = row.payload.as_ref().map(serde_json::Value::to_string);
upsert_row(
conn,
&row.collection,
&row.pk,
payload.as_deref(),
row.version,
row.deleted,
&row.updated_at.to_rfc3339(),
)
}
fn current_server_version(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
) -> Result<Version, diesel::result::Error> {
let row =
sql_query("SELECT server_version FROM autumn_sync_rows WHERE collection = ? AND pk = ?")
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.get_result::<VersionRecord>(conn)
.optional()?;
Ok(row.map_or(0, |record| record.server_version))
}
fn record_acked_version(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
version: Version,
) -> Result<(), diesel::result::Error> {
sql_query(
"UPDATE autumn_sync_rows SET server_version = ? \
WHERE collection = ? AND pk = ? AND server_version < ?",
)
.bind::<BigInt, _>(version)
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.bind::<BigInt, _>(version)
.execute(conn)
.map(|_| ())
}
fn rebase_surviving_pending(
conn: &mut SqliteConnection,
change: &Change,
acked_version: Version,
) -> Result<(), diesel::result::Error> {
sql_query(
"UPDATE autumn_sync_pending SET base_version = ? \
WHERE collection = ? AND pk = ? AND base_version = ? AND base_version < ?",
)
.bind::<BigInt, _>(acked_version)
.bind::<Text, _>(&change.collection)
.bind::<Text, _>(&change.pk)
.bind::<BigInt, _>(change.base_version)
.bind::<BigInt, _>(acked_version)
.execute(conn)
.map(|_| ())
}
fn has_pending(
conn: &mut SqliteConnection,
collection: &str,
pk: &str,
) -> Result<bool, diesel::result::Error> {
let count = sql_query(
"SELECT COUNT(*) AS count FROM autumn_sync_pending WHERE collection = ? AND pk = ?",
)
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.get_result::<CountRecord>(conn)?;
Ok(count.count > 0)
}
fn apply_remote_rows_inner(
conn: &mut SqliteConnection,
rows: &[RemoteRow],
) -> Result<usize, diesel::result::Error> {
let mut applied = 0;
for row in rows {
if has_pending(conn, &row.collection, &row.pk)? {
continue;
}
if current_server_version(conn, &row.collection, &row.pk)? > row.version {
continue;
}
upsert_remote_row(conn, row)?;
applied += 1;
}
Ok(applied)
}
#[derive(Clone)]
pub struct SyncStore {
conn: Arc<Mutex<SqliteConnection>>,
device_id: String,
}
impl std::fmt::Debug for SyncStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SyncStore")
.field("device_id", &self.device_id)
.finish_non_exhaustive()
}
}
impl SyncStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self, SyncError> {
let path = path.as_ref();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(store_err)?;
}
let path_str = path
.to_str()
.ok_or_else(|| SyncError::Store("store path is not valid UTF-8".into()))?;
let mut conn = SqliteConnection::establish(path_str).map_err(store_err)?;
conn.batch_execute("PRAGMA busy_timeout = 5000;")
.map_err(store_err)?;
let mut attempts = 0;
loop {
let result = conn
.batch_execute(
"PRAGMA journal_mode = WAL; \
PRAGMA synchronous = NORMAL; \
PRAGMA foreign_keys = ON;",
)
.and_then(|()| conn.batch_execute(SCHEMA_DDL));
match result {
Ok(()) => break,
Err(err) if attempts < 100 && err.to_string().contains("database is locked") => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(err) => return Err(store_err(err)),
}
}
sql_query(
"INSERT INTO autumn_sync_state (key, value) VALUES (?, ?) \
ON CONFLICT (key) DO NOTHING",
)
.bind::<Text, _>(STATE_DEVICE_ID)
.bind::<Text, _>(uuid::Uuid::new_v4().to_string())
.execute(&mut conn)
.map_err(store_err)?;
let device_id = get_state(&mut conn, STATE_DEVICE_ID)?
.ok_or_else(|| SyncError::Store("device id was not persisted".into()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
device_id,
})
}
fn lock(&self) -> Result<MutexGuard<'_, SqliteConnection>, SyncError> {
self.conn
.lock()
.map_err(|_| SyncError::Store("sync store mutex poisoned".into()))
}
pub fn set_identity(&self, identity: &str) -> Result<(), SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
let previous = sql_query("SELECT value FROM autumn_sync_state WHERE key = ?")
.bind::<Text, _>(STATE_IDENTITY)
.get_result::<StateRecord>(conn)
.optional()?
.map(|record| record.value);
if previous.as_deref() == Some(identity) {
return Ok(());
}
if previous.is_some() {
sql_query("DELETE FROM autumn_sync_rows").execute(conn)?;
sql_query("DELETE FROM autumn_sync_pending").execute(conn)?;
sql_query("DELETE FROM autumn_sync_state WHERE key = ?")
.bind::<Text, _>(STATE_CURSOR)
.execute(conn)?;
}
set_state(conn, STATE_IDENTITY, identity)
})
.map_err(store_err)
}
fn write_local(
&self,
collection: &str,
pk: &str,
op: Op,
payload: Option<&str>,
) -> Result<(), SyncError> {
let now = Utc::now().to_rfc3339();
let op_name = match op {
Op::Upsert => "upsert",
Op::Delete => "delete",
};
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
let base_version = base_version_for(conn, collection, pk)?;
let server_version = current_server_version(conn, collection, pk)?;
upsert_row(
conn,
collection,
pk,
payload,
server_version,
op == Op::Delete,
&now,
)?;
replace_pending(conn, collection, pk, op_name, payload, base_version, &now)
})
.map_err(store_err)
}
pub fn put<T: Serialize>(
&self,
collection: &str,
pk: &str,
value: &T,
) -> Result<(), SyncError> {
let payload = serde_json::to_string(value)?;
self.write_local(collection, pk, Op::Upsert, Some(&payload))
}
pub fn delete(&self, collection: &str, pk: &str) -> Result<(), SyncError> {
self.write_local(collection, pk, Op::Delete, None)
}
pub fn get<T: DeserializeOwned>(
&self,
collection: &str,
pk: &str,
) -> Result<Option<T>, SyncError> {
let mut conn = self.lock()?;
let record = sql_query(
"SELECT payload, deleted FROM autumn_sync_rows WHERE collection = ? AND pk = ?",
)
.bind::<Text, _>(collection)
.bind::<Text, _>(pk)
.get_result::<RowRecord>(&mut *conn)
.optional()
.map_err(store_err)?;
drop(conn);
if let Some(RowRecord {
payload: Some(payload),
deleted: false,
}) = record
{
Ok(Some(serde_json::from_str(&payload)?))
} else {
Ok(None)
}
}
pub fn list<T: DeserializeOwned>(
&self,
collection: &str,
) -> Result<Vec<(String, T)>, SyncError> {
let mut conn = self.lock()?;
let records = sql_query(
"SELECT pk, payload FROM autumn_sync_rows \
WHERE collection = ? AND deleted = 0 ORDER BY pk",
)
.bind::<Text, _>(collection)
.get_results::<ListRecord>(&mut *conn)
.map_err(store_err)?;
drop(conn);
records
.into_iter()
.filter_map(|record| record.payload.map(|payload| (record.pk, payload)))
.map(|(pk, payload)| Ok((pk, serde_json::from_str(&payload)?)))
.collect()
}
pub fn pending_count(&self) -> Result<u64, SyncError> {
let mut conn = self.lock()?;
let record = sql_query("SELECT COUNT(*) AS count FROM autumn_sync_pending")
.get_result::<CountRecord>(&mut *conn)
.map_err(store_err)?;
drop(conn);
Ok(u64::try_from(record.count).unwrap_or(0))
}
pub fn pending_changes(&self, limit: usize) -> Result<Vec<Change>, SyncError> {
let mut conn = self.lock()?;
let records = sql_query(
"SELECT change_id, collection, pk, op, payload, base_version, updated_at \
FROM autumn_sync_pending ORDER BY rowid LIMIT ?",
)
.bind::<BigInt, _>(i64::try_from(limit).unwrap_or(i64::MAX))
.get_results::<PendingRecord>(&mut *conn)
.map_err(store_err)?;
drop(conn);
records
.into_iter()
.map(|record| {
let op = match record.op.as_str() {
"delete" => Op::Delete,
_ => Op::Upsert,
};
let payload = record
.payload
.as_deref()
.map(serde_json::from_str)
.transpose()?;
Ok(Change {
change_id: record.change_id,
collection: record.collection,
pk: record.pk,
op,
payload,
base_version: record.base_version,
updated_at: parse_timestamp(&record.updated_at)?,
})
})
.collect()
}
pub fn device_id(&self) -> Result<String, SyncError> {
Ok(self.device_id.clone())
}
pub fn cursor(&self) -> Result<Version, SyncError> {
let mut conn = self.lock()?;
let value = get_state(&mut conn, STATE_CURSOR)?;
drop(conn);
Ok(value.and_then(|v| v.parse().ok()).unwrap_or(0))
}
pub(crate) fn confirm_pushed(
&self,
changes: &[Change],
outcomes: &[ChangeOutcome],
) -> Result<(), SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
for (change, outcome) in changes.iter().zip(outcomes) {
sql_query("DELETE FROM autumn_sync_pending WHERE change_id = ?")
.bind::<Text, _>(&change.change_id)
.execute(conn)?;
match outcome {
ChangeOutcome::Applied { version }
| ChangeOutcome::AlreadyApplied { version } => {
record_acked_version(conn, &change.collection, &change.pk, *version)?;
rebase_surviving_pending(conn, change, *version)?;
}
ChangeOutcome::Resolved { row } => {
if has_pending(conn, &row.collection, &row.pk)? {
record_acked_version(conn, &row.collection, &row.pk, row.version)?;
} else {
upsert_remote_row(conn, row)?;
}
}
}
}
Ok(())
})
.map_err(store_err)
}
#[cfg(test)]
pub(crate) fn apply_remote_rows(&self, rows: &[RemoteRow]) -> Result<usize, SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
apply_remote_rows_inner(conn, rows)
})
.map_err(store_err)
}
pub(crate) fn apply_remote_page(
&self,
rows: &[RemoteRow],
cursor: Version,
) -> Result<usize, SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
let applied = apply_remote_rows_inner(conn, rows)?;
set_state(conn, STATE_CURSOR, &cursor.to_string())?;
Ok(applied)
})
.map_err(store_err)
}
pub(crate) fn has_synced_rows(&self) -> Result<bool, SyncError> {
let mut conn = self.lock()?;
let count = sql_query(
"SELECT COUNT(*) AS count FROM autumn_sync_rows \
WHERE server_version > 0 AND NOT EXISTS (\
SELECT 1 FROM autumn_sync_pending p \
WHERE p.collection = autumn_sync_rows.collection \
AND p.pk = autumn_sync_rows.pk)",
)
.get_result::<CountRecord>(&mut *conn)
.map_err(store_err)?;
drop(conn);
Ok(count.count > 0)
}
pub(crate) fn reconcile_snapshot(
&self,
rows: &[RemoteRow],
cursor: Version,
) -> Result<usize, SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
let applied = apply_remote_rows_inner(conn, rows)?;
let snapshot_keys: std::collections::HashSet<(&str, &str)> = rows
.iter()
.map(|row| (row.collection.as_str(), row.pk.as_str()))
.collect();
let existing = sql_query(
"SELECT collection, pk FROM autumn_sync_rows \
WHERE server_version > 0 AND NOT EXISTS (\
SELECT 1 FROM autumn_sync_pending p \
WHERE p.collection = autumn_sync_rows.collection \
AND p.pk = autumn_sync_rows.pk)",
)
.get_results::<RowKeyRecord>(conn)?;
for record in existing {
if !snapshot_keys.contains(&(record.collection.as_str(), record.pk.as_str())) {
sql_query("DELETE FROM autumn_sync_rows WHERE collection = ? AND pk = ?")
.bind::<Text, _>(&record.collection)
.bind::<Text, _>(&record.pk)
.execute(conn)?;
}
}
set_state(conn, STATE_CURSOR, &cursor.to_string())?;
Ok(applied)
})
.map_err(store_err)
}
pub(crate) fn prune_acked_tombstones(&self, horizon: Version) -> Result<u64, SyncError> {
let mut conn = self.lock()?;
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
let removed = sql_query(
"DELETE FROM autumn_sync_rows \
WHERE deleted = 1 AND server_version > 0 AND server_version <= ? \
AND NOT EXISTS (\
SELECT 1 FROM autumn_sync_pending p \
WHERE p.collection = autumn_sync_rows.collection \
AND p.pk = autumn_sync_rows.pk)",
)
.bind::<BigInt, _>(horizon)
.execute(conn)?;
Ok(removed as u64)
})
.map_err(store_err)
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use serde_json::json;
use super::*;
fn open_temp() -> (tempfile::TempDir, SyncStore) {
let dir = tempfile::tempdir().expect("tempdir");
let store = SyncStore::open(dir.path().join("sync.db")).expect("open");
(dir, store)
}
fn remote_row(collection: &str, pk: &str, version: Version, deleted: bool) -> RemoteRow {
RemoteRow {
collection: collection.to_owned(),
pk: pk.to_owned(),
payload: (!deleted).then(|| json!({"title": format!("server v{version}")})),
version,
deleted,
updated_at: Utc::now(),
device_id: "server-device".to_owned(),
}
}
#[test]
fn already_applied_records_the_original_acked_version() {
let (_dir, store) = open_temp();
store
.put("notes", "n1", &json!({"title": "v1"}))
.expect("put");
let changes = store.pending_changes(10).expect("pending");
assert_eq!(changes.len(), 1);
store
.confirm_pushed(&changes, &[ChangeOutcome::AlreadyApplied { version: 7 }])
.expect("confirm");
assert_eq!(store.pending_count().expect("count"), 0);
store
.put("notes", "n1", &json!({"title": "v2"}))
.expect("re-put");
let next = store.pending_changes(10).expect("pending");
assert_eq!(
next[0].base_version, 7,
"the next edit must be based on the acked version, not 0"
);
}
#[test]
fn stale_already_applied_ack_never_regresses_the_recorded_version() {
let (_dir, store) = open_temp();
store
.put("notes", "n1", &json!({"title": "v1"}))
.expect("put");
let changes = store.pending_changes(10).expect("pending");
store
.confirm_pushed(&changes, &[ChangeOutcome::Applied { version: 9 }])
.expect("confirm applied");
store
.put("notes", "n1", &json!({"title": "v2"}))
.expect("re-put");
let retry = store.pending_changes(10).expect("pending");
store
.confirm_pushed(&retry, &[ChangeOutcome::AlreadyApplied { version: 7 }])
.expect("stale ack");
store
.put("notes", "n1", &json!({"title": "v3"}))
.expect("third put");
let next = store.pending_changes(10).expect("pending");
assert_eq!(next[0].base_version, 9, "acked version must never regress");
}
#[test]
fn identity_switch_clears_rows_cursor_and_pending() {
let (_dir, store) = open_temp();
store.set_identity("user:alice").expect("bind alice");
store
.apply_remote_page(&[remote_row("notes", "n1", 5, false)], 5)
.expect("apply page");
store
.put("notes", "local", &json!({"title": "alice's draft"}))
.expect("put");
assert_eq!(store.cursor().expect("cursor"), 5);
assert_eq!(store.pending_count().expect("pending"), 1);
let device_before = store.device_id().expect("device");
store.set_identity("user:alice").expect("rebind alice");
assert_eq!(store.cursor().expect("cursor"), 5);
assert_eq!(store.pending_count().expect("pending"), 1);
store.set_identity("user:bob").expect("bind bob");
assert_eq!(store.cursor().expect("cursor"), 0, "cursor reset");
assert_eq!(store.pending_count().expect("pending"), 0, "outbox reset");
assert!(
store
.get::<serde_json::Value>("notes", "n1")
.expect("get")
.is_none(),
"the previous account's cached rows must be gone"
);
assert!(
store
.get::<serde_json::Value>("notes", "local")
.expect("get")
.is_none(),
"the previous account's local edits must be gone"
);
assert_eq!(store.device_id().expect("device"), device_before);
}
#[test]
fn identity_persists_across_reopen_and_first_bind_adopts() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("sync.db");
{
let store = SyncStore::open(&path).expect("open");
store
.put("notes", "n1", &json!({"title": "kept"}))
.expect("put");
store.set_identity("user:alice").expect("first bind");
assert!(
store
.get::<serde_json::Value>("notes", "n1")
.expect("get")
.is_some(),
"the first bind must adopt existing single-user data"
);
}
{
let store = SyncStore::open(&path).expect("reopen");
store.set_identity("user:alice").expect("rebind");
assert!(
store
.get::<serde_json::Value>("notes", "n1")
.expect("get")
.is_some(),
"the persisted identity must survive a reopen"
);
store.set_identity("user:bob").expect("switch");
assert!(
store
.get::<serde_json::Value>("notes", "n1")
.expect("get")
.is_none(),
"an account switch after reopen must still reset"
);
}
}
#[test]
fn resolved_outcome_does_not_clobber_a_newer_pending_local_write() {
let (_dir, store) = open_temp();
store
.put("notes", "n1", &json!({"title": "pushed"}))
.expect("put");
let batch = store.pending_changes(10).expect("batch");
store
.put("notes", "n1", &json!({"title": "newer local"}))
.expect("concurrent put");
let resolved = remote_row("notes", "n1", 5, false);
store
.confirm_pushed(&batch, &[ChangeOutcome::Resolved { row: resolved }])
.expect("confirm");
let visible: Option<serde_json::Value> = store.get("notes", "n1").expect("get");
assert_eq!(
visible.and_then(|v| v["title"].as_str().map(str::to_owned)),
Some("newer local".to_owned()),
"read-your-writes: the newer pending write must stay visible"
);
assert_eq!(
store.pending_count().expect("count"),
1,
"the newer write's journal entry must survive the confirmation"
);
let next = store.pending_changes(10).expect("pending");
assert_ne!(
next[0].change_id, batch[0].change_id,
"the surviving entry is the newer coalesced write"
);
assert_eq!(
next[0].base_version, batch[0].base_version,
"a Resolved outcome must NOT re-base the survivor: it was written \
without knowledge of the resolution and must go through the \
resolver on its own push"
);
}
#[test]
fn clean_ack_rebases_the_surviving_coalesced_write() {
let (_dir, store) = open_temp();
store
.put("notes", "n1", &json!({"title": "pushed"}))
.expect("put");
let batch = store.pending_changes(10).expect("batch");
assert_eq!(batch[0].base_version, 0);
store
.put("notes", "n1", &json!({"title": "newer local"}))
.expect("concurrent put");
store
.confirm_pushed(&batch, &[ChangeOutcome::Applied { version: 5 }])
.expect("confirm");
let next = store.pending_changes(10).expect("pending");
assert_eq!(next.len(), 1, "the coalesced write survives");
assert_eq!(
next[0].base_version, 5,
"the survivor must be re-based onto the acked version so it \
applies cleanly instead of false-conflicting"
);
store
.confirm_pushed(&next, &[ChangeOutcome::Applied { version: 6 }])
.expect("confirm survivor");
assert_eq!(store.pending_count().expect("count"), 0);
store
.put("notes", "n1", &json!({"title": "v3"}))
.expect("third put");
assert_eq!(
store.pending_changes(10).expect("pending")[0].base_version,
6
);
}
#[test]
fn clean_ack_rebases_only_the_entry_built_on_the_acked_change() {
let (_dir, store) = open_temp();
store
.apply_remote_rows(&[remote_row("notes", "n1", 9, false)])
.expect("apply");
store
.put("notes", "other", &json!({"title": "x"}))
.expect("put other");
let batch = store.pending_changes(10).expect("batch");
store
.put("notes", "n1", &json!({"title": "edit"}))
.expect("put n1");
store
.confirm_pushed(&batch, &[ChangeOutcome::Applied { version: 12 }])
.expect("confirm");
let pending = store.pending_changes(10).expect("pending");
assert_eq!(pending.len(), 1);
assert_eq!(
pending[0].base_version, 9,
"an unrelated row's pending base must be untouched"
);
}
#[test]
fn resolved_outcome_applies_when_no_newer_write_exists() {
let (_dir, store) = open_temp();
store
.put("notes", "n1", &json!({"title": "mine"}))
.expect("put");
let batch = store.pending_changes(10).expect("batch");
let resolved = remote_row("notes", "n1", 5, false);
store
.confirm_pushed(&batch, &[ChangeOutcome::Resolved { row: resolved }])
.expect("confirm");
let visible: Option<serde_json::Value> = store.get("notes", "n1").expect("get");
assert_eq!(
visible.and_then(|v| v["title"].as_str().map(str::to_owned)),
Some("server v5".to_owned()),
"with no newer pending write the resolved row applies locally"
);
assert_eq!(store.pending_count().expect("count"), 0);
}
#[test]
fn null_documents_round_trip_through_journal_rows_and_pulls() {
let (_dir, store) = open_temp();
store
.put("notes", "opt", &None::<String>)
.expect("put none");
let pending = store.pending_changes(10).expect("pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].op, Op::Upsert);
assert_eq!(
pending[0].payload,
Some(serde_json::Value::Null),
"the journal must carry a PRESENT null, not an omitted payload"
);
let value: Option<serde_json::Value> = store.get("notes", "opt").expect("get");
assert_eq!(
value,
Some(serde_json::Value::Null),
"a null document is a present row"
);
store.delete("notes", "opt").expect("delete");
let pending = store.pending_changes(10).expect("pending");
assert_eq!(pending[0].op, Op::Delete);
assert_eq!(pending[0].payload, None);
let mut remote = remote_row("notes", "pulled-null", 5, false);
remote.payload = Some(serde_json::Value::Null);
store.apply_remote_rows(&[remote]).expect("apply");
let value: Option<serde_json::Value> = store.get("notes", "pulled-null").expect("get");
assert_eq!(value, Some(serde_json::Value::Null));
let listed: Vec<(String, serde_json::Value)> = store.list("notes").expect("list");
assert!(
listed
.iter()
.any(|(pk, v)| pk == "pulled-null" && v.is_null()),
"a null-payload row must appear in listings: {listed:?}"
);
}
#[test]
fn apply_remote_page_persists_rows_and_cursor_together() {
let (_dir, store) = open_temp();
let applied = store
.apply_remote_page(&[remote_row("notes", "n1", 4, false)], 4)
.expect("apply page");
assert_eq!(applied, 1);
assert_eq!(
store.cursor().expect("cursor"),
4,
"the cursor must advance in the same transaction as the rows"
);
store.apply_remote_page(&[], 9).expect("empty page");
assert_eq!(store.cursor().expect("cursor"), 9);
}
#[test]
fn has_synced_rows_sees_only_acked_rows_without_pending_entries() {
let (_dir, store) = open_temp();
assert!(!store.has_synced_rows().expect("fresh store"));
store
.put("notes", "mine", &json!({"title": "local"}))
.expect("put");
assert!(!store.has_synced_rows().expect("pending only"));
store
.apply_remote_rows(&[remote_row("notes", "n1", 4, false)])
.expect("apply");
assert!(store.has_synced_rows().expect("synced row"));
store.reconcile_snapshot(&[], 0).expect("reconcile");
assert!(!store.has_synced_rows().expect("after reconcile"));
assert_eq!(store.pending_count().expect("count"), 1);
}
#[test]
fn prune_acked_tombstones_drops_only_gcd_acked_tombstones() {
let (_dir, store) = open_temp();
store
.apply_remote_rows(&[remote_row("notes", "gone", 3, true)])
.expect("apply tombstone");
store
.apply_remote_rows(&[remote_row("notes", "recent", 9, true)])
.expect("apply recent tombstone");
store
.put("notes", "pending", &json!({"title": "x"}))
.expect("put");
store.delete("notes", "pending").expect("delete");
store
.apply_remote_rows(&[remote_row("notes", "live", 4, false)])
.expect("apply live");
let removed = store.prune_acked_tombstones(5).expect("prune");
assert_eq!(removed, 1, "only the acked sub-horizon tombstone goes");
let live: Vec<(String, serde_json::Value)> = store.list("notes").expect("list");
assert_eq!(live.len(), 1, "live rows survive pruning");
assert_eq!(live[0].0, "live");
assert_eq!(
store.pending_count().expect("count"),
1,
"the pending (coalesced put+delete) journal entry is untouched"
);
assert_eq!(store.prune_acked_tombstones(5).expect("re-prune"), 0);
}
#[test]
#[allow(clippy::needless_collect)]
fn concurrent_first_opens_agree_on_one_device_id() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("sync.db");
let handles: Vec<_> = (0..4)
.map(|_| {
let path = path.clone();
std::thread::spawn(move || {
SyncStore::open(&path)
.expect("open")
.device_id()
.expect("device id")
})
})
.collect();
let ids: Vec<String> = handles
.into_iter()
.map(|h| h.join().expect("join"))
.collect();
assert!(
ids.windows(2).all(|w| w[0] == w[1]),
"every concurrent opener must see the same persisted device id: {ids:?}"
);
let reopened = SyncStore::open(&path).expect("reopen");
assert_eq!(reopened.device_id().expect("device id"), ids[0]);
}
}