diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index f8ffb61..976ada8 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -362,7 +362,7 @@ pub struct AjoinEntry { // A channel's on/off options (ChanServ SET). Typed, not a bag of string flags, // so a new option is one field and the compiler proves every place handles it. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ChanSettings { // Append "(requested by )" to ChanServ KICK reasons. #[serde(default)] @@ -1101,7 +1101,19 @@ impl Db { if !c.entrymsg.is_empty() { snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); } - if c.settings.signkick || c.settings.private || c.settings.peace || c.settings.secureops || c.settings.keeptopic || c.settings.topiclock { + if !c.url.is_empty() { + snapshot.push(Event::ChannelUrlSet { channel: c.name.clone(), url: c.url.clone() }); + } + if !c.email.is_empty() { + snapshot.push(Event::ChannelEmailSet { channel: c.name.clone(), email: c.email.clone() }); + } + if let Some(s) = &c.successor { + snapshot.push(Event::ChannelSuccessorSet { channel: c.name.clone(), successor: Some(s.clone()) }); + } + if c.noexpire { + snapshot.push(Event::ChannelNoExpire { channel: c.name.clone(), on: true }); + } + if c.settings != ChanSettings::default() { snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings }); } if !c.topic.is_empty() { @@ -1182,6 +1194,9 @@ impl Db { for e in &self.net.sess_exceptions { snapshot.push(Event::SessionExceptionAdded { mask: e.mask.clone(), limit: e.limit, reason: e.reason.clone() }); } + for j in &self.net.jupes { + snapshot.push(Event::JupeAdded { name: j.name.clone(), sid: j.sid.clone(), reason: j.reason.clone() }); + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 0380671..5ebd297 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -480,45 +480,44 @@ // Fold parity: the live state after a broad sequence of writes must equal the // state rebuilt by replaying the same log — every event's manual live mutation // has to match its `apply()` arm, or a restart silently changes the data. - #[test] - fn live_state_matches_replay_after_a_broad_sequence() { - let p = tmp("foldparity"); - let snapshot = |db: &Db| -> String { - let mut a: Vec<_> = db.accounts().cloned().collect(); - a.sort_by(|x, y| x.name.cmp(&y.name)); - let mut c: Vec<_> = db.channels().cloned().collect(); - c.sort_by(|x, y| x.name.cmp(&y.name)); - let mut b: Vec<_> = db.bots().cloned().collect(); - b.sort_by(|x, y| x.nick.cmp(&y.nick)); - let mut gn = db.groups(); - gn.sort(); - let gv: Vec<_> = gn.iter().filter_map(|n| db.group(n)).collect(); - let mut ak = db.akills(); - ak.sort_by(|x, y| x.mask.cmp(&y.mask)); - let mut fb = db.forbids(); - fb.sort_by(|x, y| x.mask.cmp(&y.mask)); - let mut of = db.vhost_offers().to_vec(); - of.sort(); - let mut vf = db.vhost_forbidden().to_vec(); - vf.sort(); - let mut jp = db.jupes(); - jp.sort(); - let mut nw = db.news("logon"); - nw.extend(db.news("oper")); - let rp = db.reports(false); - let hp = db.help_tickets(false); - let mut se = db.session_exceptions(); - se.sort(); - let mut op = db.opers_list(); - op.sort(); - let vt = db.vhost_template().map(str::to_string); - format!("{a:?}\n{c:?}\n{b:?}\n{gv:?}\n{ak:?}\n{fb:?}\n{of:?}\n{vf:?}\n{jp:?}\n{nw:?}\n{rp:?}\n{hp:?}\n{se:?}\n{op:?}\n{vt:?}") - }; + // A canonical Debug snapshot of every persisted collection, sorted so the + // string is order-independent. Shared by the fold-parity and compaction tests. + fn broad_snapshot(db: &Db) -> String { + let mut a: Vec<_> = db.accounts().cloned().collect(); + a.sort_by(|x, y| x.name.cmp(&y.name)); + let mut c: Vec<_> = db.channels().cloned().collect(); + c.sort_by(|x, y| x.name.cmp(&y.name)); + let mut b: Vec<_> = db.bots().cloned().collect(); + b.sort_by(|x, y| x.nick.cmp(&y.nick)); + let mut gn = db.groups(); + gn.sort(); + let gv: Vec<_> = gn.iter().filter_map(|n| db.group(n)).collect(); + let mut ak = db.akills(); + ak.sort_by(|x, y| x.mask.cmp(&y.mask)); + let mut fb = db.forbids(); + fb.sort_by(|x, y| x.mask.cmp(&y.mask)); + let mut of = db.vhost_offers().to_vec(); + of.sort(); + let mut vf = db.vhost_forbidden().to_vec(); + vf.sort(); + let mut jp = db.jupes(); + jp.sort(); + let mut nw = db.news("logon"); + nw.extend(db.news("oper")); + let rp = db.reports(false); + let hp = db.help_tickets(false); + let mut se = db.session_exceptions(); + se.sort(); + let mut op = db.opers_list(); + op.sort(); + let vt = db.vhost_template().map(str::to_string); + format!("{a:?}\n{c:?}\n{b:?}\n{gv:?}\n{ak:?}\n{fb:?}\n{of:?}\n{vf:?}\n{jp:?}\n{nw:?}\n{rp:?}\n{hp:?}\n{se:?}\n{op:?}\n{vt:?}") + } - let live = { - let mut db = Db::open(&p, "N1"); - db.scram_iterations = 4096; - db.register("alice", "pw", Some("a@x.io".into())).unwrap(); + // Exercise ~45 event types, ending with a drop that must purge references. + fn build_broad_state(db: &mut Db) { + db.scram_iterations = 4096; + db.register("alice", "pw", Some("a@x.io".into())).unwrap(); db.register("bob", "pw", None).unwrap(); db.register("carol", "pw", None).unwrap(); db.register_channel("#chan", "bob").unwrap(); @@ -569,17 +568,44 @@ db.help_take(hid, "bob"); db.oper_grant("carol", vec!["admin".into()], None); db.session_except_add("*@trusted.host", 10, "trusted"); - db.group_set_founder("!other", "carol").unwrap(); - // The compound one: dropping alice must purge her access, successorship - // and group standing — identically in the live path and on replay. - db.drop_account("alice").unwrap(); - db.drop_channel("#other").unwrap(); - snapshot(&db) + db.group_set_founder("!other", "carol").unwrap(); + // The compound one: dropping alice must purge her access, successorship + // and group standing — identically in the live path and on replay. + db.drop_account("alice").unwrap(); + db.drop_channel("#other").unwrap(); + } + + // Fold parity: live state after the sequence == state replayed from the log. + #[test] + fn live_state_matches_replay_after_a_broad_sequence() { + let p = tmp("foldparity"); + let live = { + let mut db = Db::open(&p, "N1"); + build_broad_state(&mut db); + broad_snapshot(&db) }; - let replayed = snapshot(&Db::open(&p, "N1")); + let replayed = broad_snapshot(&Db::open(&p, "N1")); assert_eq!(live, replayed, "live state must equal the state replayed from the log"); } + // Compaction parity: rewriting the log to a snapshot must not lose anything — + // the state after compact()+reopen must equal the state before. + #[test] + fn compaction_preserves_all_state() { + let p = tmp("compactparity"); + { + let mut db = Db::open(&p, "N1"); + build_broad_state(&mut db); + } + let before = broad_snapshot(&Db::open(&p, "N1")); + { + let mut db = Db::open(&p, "N1"); + db.compact().unwrap(); + } + let after = broad_snapshot(&Db::open(&p, "N1")); + assert_eq!(before, after, "compaction must preserve every persisted field"); + } + // Dropping an account erases every reference to it — channel access and // successorship, group membership and foundership — so a later re-registration // of the same name can't inherit its standing. Holds live and after replay.