#![cfg(feature = "sqlite")]
use distributed::{
sourced, Aggregate, AggregateBuilder, Entity, Snapshot, SnapshotRecord, SnapshotStore,
Snapshottable, SqliteRepository, StreamIdentity,
};
use serde::{Deserialize, Serialize};
#[derive(Default, Snapshot)]
struct Counter {
pub entity: Entity,
pub total: i64,
}
#[sourced(entity, aggregate_type = "sqlite.snap.counter")]
impl Counter {
#[event("added")]
fn add(&mut self, id: String, amount: i64) {
if self.entity.id().is_empty() {
self.entity.set_id(&id);
}
self.total += amount;
}
}
async fn repository() -> SqliteRepository {
SqliteRepository::connect_and_migrate("sqlite::memory:")
.await
.expect("sqlite in-memory repository should migrate")
}
fn identity(id: &str) -> StreamIdentity {
StreamIdentity::new(Counter::aggregate_type(), id).unwrap()
}
async fn seed_counter(repo: &SqliteRepository, id: &str, n: i64) {
let counter_repo = repo.clone().aggregate::<Counter>().with_snapshots(1);
let mut counter = Counter::default();
for _ in 0..n {
counter.add(id.into(), 1).unwrap();
counter_repo.commit(&mut counter).await.unwrap();
counter = counter_repo.get(id).await.unwrap().unwrap();
}
}
async fn count_event_rows(repo: &SqliteRepository, id: &str) -> i64 {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM aggregate_events WHERE aggregate_type = ? AND aggregate_id = ?",
)
.bind(Counter::aggregate_type())
.bind(id)
.fetch_one(repo.pool())
.await
.unwrap()
}
#[tokio::test]
async fn snapshot_hydrated_load_does_not_read_pre_snapshot_rows() {
let repo = repository().await;
let id = "c-tail";
seed_counter(&repo, id, 5).await;
let snap = repo.get_snapshot(&identity(id)).await.unwrap().unwrap();
assert_eq!(snap.version, 5, "snapshot should cover the whole stream");
sqlx::query("DELETE FROM aggregate_events WHERE aggregate_type = ? AND aggregate_id = ? AND sequence <= ?")
.bind(Counter::aggregate_type())
.bind(id)
.bind(5_i64)
.execute(repo.pool())
.await
.unwrap();
assert_eq!(
count_event_rows(&repo, id).await,
0,
"all pre-snapshot rows removed; only the snapshot remains"
);
let counter_repo = repo.clone().aggregate::<Counter>().with_snapshots(1);
let loaded = counter_repo.get(id).await.unwrap().unwrap();
assert_eq!(loaded.total, 5, "state reconstructed from snapshot alone");
assert_eq!(
loaded.entity.version(),
5,
"version reflects true stream position despite no rows in memory"
);
assert_eq!(loaded.entity.snapshot_version(), 5);
assert!(
loaded.entity.events().is_empty(),
"snapshot-hydrated entity holds only the tail (empty here)"
);
}
#[tokio::test]
async fn snapshot_plus_tail_only_reads_post_snapshot_rows() {
let repo = repository().await;
let id = "c-partial";
seed_counter(&repo, id, 3).await;
assert_eq!(
repo.get_snapshot(&identity(id))
.await
.unwrap()
.unwrap()
.version,
3
);
let plain_repo = repo.clone().aggregate::<Counter>();
let mut counter = plain_repo.get(id).await.unwrap().unwrap();
counter.add(id.into(), 1).unwrap();
counter.add(id.into(), 1).unwrap();
plain_repo.commit(&mut counter).await.unwrap();
sqlx::query("DELETE FROM aggregate_events WHERE aggregate_type = ? AND aggregate_id = ? AND sequence <= ?")
.bind(Counter::aggregate_type())
.bind(id)
.bind(3_i64)
.execute(repo.pool())
.await
.unwrap();
assert_eq!(
count_event_rows(&repo, id).await,
2,
"only the tail rows remain"
);
let snap_repo = repo.clone().aggregate::<Counter>().with_snapshots(1);
let loaded = snap_repo.get(id).await.unwrap().unwrap();
assert_eq!(loaded.total, 5, "snapshot(3) + tail(2) = 5");
assert_eq!(loaded.entity.version(), 5);
assert_eq!(
loaded.entity.events().len(),
2,
"only the post-snapshot tail is held in memory"
);
assert_eq!(
loaded
.entity
.events()
.iter()
.map(|e| e.sequence)
.collect::<Vec<_>>(),
vec![4, 5],
"the in-memory tail is exactly the post-snapshot rows"
);
}
#[derive(Serialize, Deserialize)]
struct LegacyCounterSnapshot {
pub legacy_flag: bool,
pub label: String,
pub total: i64,
pub id: String,
}
#[tokio::test]
async fn stale_schema_snapshot_falls_back_to_replay_with_correct_state() {
let repo = repository().await;
let id = "c-stale";
let plain_repo = repo.clone().aggregate::<Counter>();
let mut counter = Counter::default();
counter.add(id.into(), 1).unwrap();
counter.add(id.into(), 2).unwrap();
counter.add(id.into(), 3).unwrap();
plain_repo.commit(&mut counter).await.unwrap();
let legacy = LegacyCounterSnapshot {
legacy_flag: true,
label: "old".into(),
total: 999,
id: id.into(),
};
let payload = bitcode::serialize(&legacy).unwrap();
assert_ne!(
Counter::SNAPSHOT_VERSION,
2,
"test assumes the current schema version is not 2"
);
repo.save_snapshot(
&identity(id),
SnapshotRecord::new(
Counter::aggregate_type(),
id,
3,
2, payload,
),
)
.await
.unwrap();
let snap_repo = repo.clone().aggregate::<Counter>().with_snapshots(1);
let loaded = snap_repo.get(id).await.unwrap().unwrap();
assert_eq!(
loaded.total, 6,
"stale-schema snapshot must not be decoded; replay yields correct state"
);
assert_eq!(loaded.entity.version(), 3);
}
#[tokio::test]
async fn snapshot_hydrated_state_equals_full_replay_state() {
let repo = repository().await;
let id = "c-equiv";
seed_counter(&repo, id, 4).await;
let plain_repo = repo.clone().aggregate::<Counter>();
let mut counter = plain_repo.get(id).await.unwrap().unwrap();
counter.add(id.into(), 10).unwrap();
counter.add(id.into(), 100).unwrap();
plain_repo.commit(&mut counter).await.unwrap();
let replayed = plain_repo.get(id).await.unwrap().unwrap();
let snap_repo = repo.clone().aggregate::<Counter>().with_snapshots(1);
let hydrated = snap_repo.get(id).await.unwrap().unwrap();
assert_eq!(replayed.total, 4 + 10 + 100);
assert_eq!(hydrated.total, replayed.total, "states must match exactly");
assert_eq!(hydrated.entity.version(), replayed.entity.version());
}