Skip to main content

forge_workspace/store/
slack.rs

1//! Slack subscription CRUD on the redb `slack_subscriptions` table.
2//!
3//! The whole [`SlackSubscription`] record is stored as serde-json keyed
4//! by its uuid bytes, the shape `store::gotify` uses. It gets its own
5//! table rather than sharing Gotify's: the two carry different record
6//! shapes, so one table would decode the other's rows as garbage.
7
8use anyhow::Context;
9use forge_primitives::slack::{SlackSubscription, SlackThreadRecord};
10use redb::{ReadableTable, TableDefinition};
11use uuid::Uuid;
12
13use super::Db;
14
15const SUBS: TableDefinition<[u8; 16], &[u8]> = TableDefinition::new("slack_subscriptions");
16
17/// Persist a subscription, replacing any prior record with the same id.
18pub fn insert(db: &Db, sub: &SlackSubscription) -> anyhow::Result<()> {
19    let value = serde_json::to_vec(sub).context("serialize subscription")?;
20    let txn = db.database().begin_write()?;
21    {
22        let mut table = txn.open_table(SUBS)?;
23        table.insert(sub.id.as_bytes(), value.as_slice())?;
24    }
25    txn.commit()?;
26    Ok(())
27}
28
29/// Every persisted subscription.
30pub fn list(db: &Db) -> anyhow::Result<Vec<SlackSubscription>> {
31    read_all(db)
32}
33
34/// Delete a subscription by id. Returns whether a record existed.
35pub fn remove(db: &Db, id: Uuid) -> anyhow::Result<bool> {
36    let txn = db.database().begin_write()?;
37    let existed = {
38        let mut table = txn.open_table(SUBS)?;
39        table.remove(id.as_bytes())?.is_some()
40    };
41    txn.commit()?;
42    Ok(existed)
43}
44
45fn read_all(db: &Db) -> anyhow::Result<Vec<SlackSubscription>> {
46    let txn = db.database().begin_read()?;
47    let table = match txn.open_table(SUBS) {
48        Ok(t) => t,
49        // A fresh database has no table until the first write; an absent
50        // table is an empty list, not an error.
51        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
52        Err(e) => return Err(e.into()),
53    };
54    let mut out = Vec::new();
55    for entry in table.iter()? {
56        let (id, value) = entry?;
57        match serde_json::from_slice::<SlackSubscription>(value.value()) {
58            Ok(sub) => out.push(sub),
59            // One undecodable record (schema drift, a corrupt blob) must not
60            // wipe the rest of the durable set - skip it and warn.
61            Err(err) => tracing::warn!(
62                target: "forge_workspace::store::slack",
63                id = %Uuid::from_bytes(id.value()),
64                error = %err,
65                "skipping Slack subscription record that failed to decode",
66            ),
67        }
68    }
69    Ok(out)
70}
71
72/// Keyed by the workspace label and the conversation id, not by the
73/// subscription: a conversation can be watched by more than one
74/// subscription, and the cursor belongs to the conversation.
75const WATERMARKS: TableDefinition<&str, &str> = TableDefinition::new("slack_watermarks");
76
77/// `\u{0}` joins the pair. A Slack conversation id is alphanumeric and a
78/// workspace label is a TOML string, so neither carries one.
79fn watermark_key(workspace: &str, conversation: &str) -> String {
80    format!("{workspace}\u{0}{conversation}")
81}
82
83/// The last `ts` delivered for a conversation in a workspace, or `None`
84/// when that conversation has never been swept.
85pub fn watermark(db: &Db, workspace: &str, conversation: &str) -> anyhow::Result<Option<String>> {
86    let key = watermark_key(workspace, conversation);
87    let txn = db.database().begin_read()?;
88    let table = match txn.open_table(WATERMARKS) {
89        Ok(t) => t,
90        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
91        Err(e) => return Err(e.into()),
92    };
93    match table.get(key.as_str())? {
94        Some(value) => Ok(Some(value.value().to_owned())),
95        None => Ok(None),
96    }
97}
98
99/// Record the last `ts` delivered for a conversation. Stored as the string
100/// Slack sent it, never as a number.
101pub fn set_watermark(db: &Db, workspace: &str, conversation: &str, ts: &str) -> anyhow::Result<()> {
102    let key = watermark_key(workspace, conversation);
103    let txn = db.database().begin_write()?;
104    {
105        let mut table = txn.open_table(WATERMARKS)?;
106        table.insert(key.as_str(), ts)?;
107    }
108    txn.commit()?;
109    Ok(())
110}
111
112/// Remove a conversation's sweep cursor, the `__mentions__` stream
113/// included. Returns whether one existed.
114pub fn remove_watermark(db: &Db, workspace: &str, conversation: &str) -> anyhow::Result<bool> {
115    let key = watermark_key(workspace, conversation);
116    let txn = db.database().begin_write()?;
117    let existed = {
118        let mut table = txn.open_table(WATERMARKS)?;
119        table.remove(key.as_str())?.is_some()
120    };
121    txn.commit()?;
122    Ok(existed)
123}
124
125/// Every conversation with a sweep cursor in one workspace, including the
126/// `__mentions__` stream when present.
127pub fn watermark_conversations(db: &Db, workspace: &str) -> anyhow::Result<Vec<String>> {
128    let prefix = format!("{workspace}\u{0}");
129    let txn = db.database().begin_read()?;
130    let table = match txn.open_table(WATERMARKS) {
131        Ok(t) => t,
132        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
133        Err(e) => return Err(e.into()),
134    };
135    let mut out = Vec::new();
136    for entry in table.iter()? {
137        let (key, _) = entry?;
138        if let Some(conversation) = key.value().strip_prefix(&prefix) {
139            out.push(conversation.to_owned());
140        }
141    }
142    Ok(out)
143}
144
145/// One followed thread per (workspace, conversation, parent) triple. The
146/// value is JSON because the owner list rides beside the verbatim reply
147/// cursor; a bare string would lose the owners.
148const THREADS: TableDefinition<&str, &str> = TableDefinition::new("slack_threads");
149
150/// `\u{0}` joins the triple; none of its parts can carry one.
151fn thread_key(workspace: &str, conversation: &str, parent_ts: &str) -> String {
152    format!("{workspace}\u{0}{conversation}\u{0}{parent_ts}")
153}
154
155/// One thread's record, or `None` when that thread is not followed.
156pub fn thread(
157    db: &Db,
158    workspace: &str,
159    conversation: &str,
160    parent_ts: &str,
161) -> anyhow::Result<Option<SlackThreadRecord>> {
162    let key = thread_key(workspace, conversation, parent_ts);
163    let txn = db.database().begin_read()?;
164    let table = match txn.open_table(THREADS) {
165        Ok(t) => t,
166        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
167        Err(e) => return Err(e.into()),
168    };
169    match table.get(key.as_str())? {
170        Some(value) => Ok(Some(serde_json::from_str(value.value())?)),
171        None => Ok(None),
172    }
173}
174
175/// Store one thread's record, replacing any prior row for the triple.
176pub fn set_thread(
177    db: &Db,
178    workspace: &str,
179    conversation: &str,
180    parent_ts: &str,
181    record: &SlackThreadRecord,
182) -> anyhow::Result<()> {
183    let key = thread_key(workspace, conversation, parent_ts);
184    let value = serde_json::to_string(record)?;
185    let txn = db.database().begin_write()?;
186    {
187        let mut table = txn.open_table(THREADS)?;
188        table.insert(key.as_str(), value.as_str())?;
189    }
190    txn.commit()?;
191    Ok(())
192}
193
194/// Every followed thread of one conversation, as (parent ts, record).
195pub fn threads_for(
196    db: &Db,
197    workspace: &str,
198    conversation: &str,
199) -> anyhow::Result<Vec<(String, SlackThreadRecord)>> {
200    let prefix = format!("{workspace}\u{0}{conversation}\u{0}");
201    let txn = db.database().begin_read()?;
202    let table = match txn.open_table(THREADS) {
203        Ok(t) => t,
204        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
205        Err(e) => return Err(e.into()),
206    };
207    let mut out = Vec::new();
208    for entry in table.iter()? {
209        let (key, value) = entry?;
210        let Some(parent_ts) = key.value().strip_prefix(&prefix) else { continue };
211        match serde_json::from_str::<SlackThreadRecord>(value.value()) {
212            Ok(record) => out.push((parent_ts.to_owned(), record)),
213            Err(err) => tracing::warn!(
214                target: "forge_workspace::store::slack",
215                error = %err,
216                "skipping Slack thread record that failed to decode",
217            ),
218        }
219    }
220    Ok(out)
221}
222
223/// Every followed thread of one workspace, across its conversations, as
224/// (conversation, parent ts, record). Backs removal-time pruning.
225pub fn threads_for_workspace(
226    db: &Db,
227    workspace: &str,
228) -> anyhow::Result<Vec<(String, String, SlackThreadRecord)>> {
229    let prefix = format!("{workspace}\u{0}");
230    let txn = db.database().begin_read()?;
231    let table = match txn.open_table(THREADS) {
232        Ok(t) => t,
233        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
234        Err(e) => return Err(e.into()),
235    };
236    let mut out = Vec::new();
237    for entry in table.iter()? {
238        let (key, value) = entry?;
239        let Some(rest) = key.value().strip_prefix(&prefix) else { continue };
240        let Some((conversation, parent_ts)) = rest.split_once('\u{0}') else { continue };
241        match serde_json::from_str::<SlackThreadRecord>(value.value()) {
242            Ok(record) => out.push((conversation.to_owned(), parent_ts.to_owned(), record)),
243            Err(err) => tracing::warn!(
244                target: "forge_workspace::store::slack",
245                error = %err,
246                "skipping Slack thread record that failed to decode",
247            ),
248        }
249    }
250    Ok(out)
251}
252
253/// Delete one thread row. Returns whether a row existed.
254pub fn remove_thread(
255    db: &Db,
256    workspace: &str,
257    conversation: &str,
258    parent_ts: &str,
259) -> anyhow::Result<bool> {
260    let key = thread_key(workspace, conversation, parent_ts);
261    let txn = db.database().begin_write()?;
262    let existed = {
263        let mut table = txn.open_table(THREADS)?;
264        table.remove(key.as_str())?.is_some()
265    };
266    txn.commit()?;
267    Ok(existed)
268}
269
270/// The newest messages forge posted itself, keyed
271/// `workspace\0conversation\0ts` and stamped with the seconds it was
272/// recorded. The sweep refuses a message whose ts is here, because forge
273/// posts as the user and the author can no longer tell them apart.
274const OWN_POSTS: TableDefinition<&str, u64> = TableDefinition::new("slack_own_posts");
275
276/// A conversation's newest own posts kept, and the age past which a row is
277/// dropped. Both are applied when that conversation records its next post,
278/// so a conversation that never posts again keeps what it had. A pruned row
279/// costs at most one self-delivery, the same cost as a lost ledger.
280const OWN_POST_KEEP: usize = 200;
281const OWN_POST_MAX_AGE_SECS: u64 = 30 * 24 * 60 * 60;
282
283fn own_post_prefix(workspace: &str, conversation: &str) -> String {
284    format!("{workspace}\u{0}{conversation}\u{0}")
285}
286
287fn own_post_key(workspace: &str, conversation: &str, ts: &str) -> String {
288    format!("{workspace}\u{0}{conversation}\u{0}{ts}")
289}
290
291/// Whether forge posted this message.
292pub fn own_post(db: &Db, workspace: &str, conversation: &str, ts: &str) -> anyhow::Result<bool> {
293    let key = own_post_key(workspace, conversation, ts);
294    let txn = db.database().begin_read()?;
295    let table = match txn.open_table(OWN_POSTS) {
296        Ok(t) => t,
297        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(false),
298        Err(e) => return Err(e.into()),
299    };
300    Ok(table.get(key.as_str())?.is_some())
301}
302
303/// Record a message forge posted at `now`, pruning the conversation's
304/// rows past either bound.
305pub fn record_own_post(
306    db: &Db,
307    workspace: &str,
308    conversation: &str,
309    ts: &str,
310    now: u64,
311) -> anyhow::Result<()> {
312    let prefix = own_post_prefix(workspace, conversation);
313    let key = own_post_key(workspace, conversation, ts);
314    let cutoff = now.saturating_sub(OWN_POST_MAX_AGE_SECS);
315    let txn = db.database().begin_write()?;
316    {
317        let mut table = txn.open_table(OWN_POSTS)?;
318        let mut kept: Vec<String> = Vec::new();
319        let mut expired: Vec<String> = Vec::new();
320        // This conversation's rows only, and only until the prefix ends:
321        // the table holds every conversation's posts.
322        for entry in table.range(prefix.as_str()..)? {
323            let (stored, recorded) = entry?;
324            let stored = stored.value();
325            if !stored.starts_with(&prefix) {
326                break;
327            }
328            // Re-recording a ts - an edit, a share read again - refreshes
329            // its row; counting it here would prune a live row each time.
330            if stored == key.as_str() {
331                continue;
332            }
333            if recorded.value() < cutoff {
334                expired.push(stored.to_owned());
335            } else {
336                kept.push(stored.to_owned());
337            }
338        }
339        for key in expired {
340            table.remove(key.as_str())?;
341        }
342        // Keys are the timestamps, so the vector is oldest first.
343        let excess = kept.len().saturating_add(1).saturating_sub(OWN_POST_KEEP);
344        for key in kept.drain(..excess) {
345            table.remove(key.as_str())?;
346        }
347        table.insert(key.as_str(), now)?;
348    }
349    txn.commit()?;
350    Ok(())
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::store::Db;
357    use forge_primitives::slack::{
358        SlackSubscriptionTarget, SlackThreadOwner, SlackThreadRecord, SlackWatchMode,
359    };
360    use tempfile::tempdir;
361    fn sub(project: &str) -> SlackSubscription {
362        SlackSubscription {
363            id: Uuid::new_v4(),
364            workspace: "acme".to_owned(),
365            project: project.to_owned(),
366            team_role: None,
367            target: SlackSubscriptionTarget::DirectMessages,
368            created_at: std::time::SystemTime::UNIX_EPOCH,
369        }
370    }
371
372    #[test]
373    fn subscription_store_round_trip() {
374        let dir = tempdir().expect("tempdir");
375        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
376
377        let s1 = sub("p1");
378        let s2 = sub("p2");
379        insert(&db, &s1).expect("insert s1");
380        insert(&db, &s2).expect("insert s2");
381        assert_eq!(list(&db).expect("list").len(), 2);
382
383        assert!(remove(&db, s1.id).expect("remove"));
384        let left = list(&db).expect("list");
385        assert_eq!(left.len(), 1);
386        assert_eq!(left[0].id, s2.id);
387    }
388
389    #[test]
390    fn an_absent_table_reads_as_empty_rather_than_erroring() {
391        let dir = tempdir().expect("tempdir");
392        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
393        assert!(list(&db).expect("a fresh database has no table yet").is_empty());
394    }
395
396    #[test]
397    fn a_record_round_trips_with_the_mention_target() {
398        let dir = tempdir().expect("tempdir");
399        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
400        let mut s = sub("p1");
401        s.target = SlackSubscriptionTarget::Mentions;
402        insert(&db, &s).expect("insert");
403        assert_eq!(list(&db).expect("list")[0].target, SlackSubscriptionTarget::Mentions);
404    }
405
406    #[test]
407    fn a_watermark_round_trips_as_the_string_slack_sent() {
408        let dir = tempdir().expect("tempdir");
409        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
410
411        // Trailing zeros and a microsecond fraction: a value that has been
412        // through an f64 comes back as "1700000000.0001" and silently moves
413        // the cursor, so pin the exact text.
414        let ts = "1700000000.000100";
415        set_watermark(&db, "acme", "C1", ts).expect("set");
416        assert_eq!(
417            watermark(&db, "acme", "C1").expect("get"),
418            Some(ts.to_owned()),
419            "the ts must survive verbatim, never as a number",
420        );
421    }
422
423    #[test]
424    fn an_unset_watermark_reads_as_none() {
425        let dir = tempdir().expect("tempdir");
426        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
427        assert_eq!(
428            watermark(&db, "acme", "C1").expect("a fresh database has no table yet"),
429            None,
430            "a conversation never swept has no cursor, which is not an error",
431        );
432    }
433
434    #[test]
435    fn a_watermark_is_scoped_to_its_workspace_and_conversation() {
436        let dir = tempdir().expect("tempdir");
437        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
438
439        set_watermark(&db, "acme", "C1", "100.000001").expect("set acme/C1");
440        set_watermark(&db, "acme", "C2", "200.000002").expect("set acme/C2");
441        set_watermark(&db, "beta", "C1", "300.000003").expect("set beta/C1");
442
443        assert_eq!(watermark(&db, "acme", "C1").expect("get"), Some("100.000001".to_owned()));
444        assert_eq!(watermark(&db, "acme", "C2").expect("get"), Some("200.000002".to_owned()));
445        assert_eq!(
446            watermark(&db, "beta", "C1").expect("get"),
447            Some("300.000003".to_owned()),
448            "the same conversation id in another workspace keeps its own cursor",
449        );
450        assert_eq!(watermark(&db, "beta", "C2").expect("get"), None);
451    }
452
453    #[test]
454    fn a_conversation_subscription_keeps_its_mode() {
455        let dir = tempdir().expect("tempdir");
456        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
457        let mut s = sub("p1");
458        s.team_role = Some("tester".to_owned());
459        s.target = SlackSubscriptionTarget::Conversation {
460            id: "C1".to_owned(),
461            name: Some("ved-test".to_owned()),
462            mode: SlackWatchMode::MentionsOnly,
463        };
464        insert(&db, &s).expect("insert");
465        let back = list(&db).expect("list");
466        assert_eq!(back[0].target, s.target);
467        assert_eq!(back[0].team_role.as_deref(), Some("tester"));
468    }
469
470    fn thread_record(cursor: &str, owners: &[(&str, Option<&str>)]) -> SlackThreadRecord {
471        SlackThreadRecord {
472            cursor: cursor.to_owned(),
473            edit_watermark: None,
474            participating: false,
475            owners: owners
476                .iter()
477                .map(|(project, role)| SlackThreadOwner {
478                    project: project.to_string(),
479                    team_role: role.map(str::to_owned),
480                })
481                .collect(),
482        }
483    }
484
485    #[test]
486    fn a_thread_record_round_trips_with_its_owners_and_verbatim_cursor() {
487        let dir = tempdir().expect("tempdir");
488        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
489
490        let record = SlackThreadRecord {
491            participating: true,
492            edit_watermark: Some("1700000000.000400".to_owned()),
493            ..thread_record("1700000000.000100", &[("forge", Some("tester")), ("forge", None)])
494        };
495        set_thread(&db, "acme", "C1", "100.000001", &record).expect("set");
496        let back = thread(&db, "acme", "C1", "100.000001").expect("get").expect("present");
497        assert_eq!(back.cursor, "1700000000.000100", "the cursor survives verbatim");
498        assert_eq!(
499            back.edit_watermark.as_deref(),
500            Some("1700000000.000400"),
501            "the edit stamp survives, or a restart re-offers the correction",
502        );
503        assert_eq!(back.owners.len(), 2, "both owners ride along");
504        assert_eq!(back.owners[0].team_role.as_deref(), Some("tester"));
505        assert!(
506            back.participating,
507            "and the verdict survives: losing it would re-read whole threads",
508        );
509    }
510
511    #[test]
512    fn a_thread_row_is_scoped_to_workspace_conversation_and_parent() {
513        let dir = tempdir().expect("tempdir");
514        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
515
516        let record = thread_record("100.1", &[("forge", None)]);
517        set_thread(&db, "acme", "C1", "100.000001", &record).expect("set acme/C1/100");
518        set_thread(&db, "acme", "C1", "200.000002", &record).expect("set acme/C1/200");
519        set_thread(&db, "beta", "C1", "100.000001", &record).expect("set beta/C1/100");
520        set_thread(&db, "acme", "C2", "100.000001", &record).expect("set acme/C2/100");
521
522        assert_eq!(
523            threads_for(&db, "acme", "C1").expect("list").len(),
524            2,
525            "only acme/C1 rows are that conversation's threads",
526        );
527        assert!(
528            thread(&db, "acme", "C1", "100.000001").expect("get").is_some(),
529            "the parent ts is part of the key, not an overwrite",
530        );
531        assert_eq!(thread(&db, "acme", "C1", "999.9").expect("get"), None);
532    }
533
534    #[test]
535    fn an_own_post_is_known_only_in_the_conversation_it_was_recorded() {
536        let dir = tempdir().expect("tempdir");
537        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
538
539        record_own_post(&db, "acme", "C1", "100.000001", 1_700_000_000).expect("record");
540
541        assert!(
542            own_post(&db, "acme", "C1", "100.000001").expect("read"),
543            "the recorded ts is ours"
544        );
545        assert!(
546            !own_post(&db, "acme", "C1", "100.000002").expect("read"),
547            "a ts never recorded is not"
548        );
549        assert!(
550            !own_post(&db, "acme", "C2", "100.000001").expect("read"),
551            "another conversation is not"
552        );
553        assert!(
554            !own_post(&db, "beta", "C1", "100.000001").expect("read"),
555            "another workspace is not"
556        );
557        assert!(
558            !own_post(&db, "acme", "C1", "100.0000010").expect("read"),
559            "the ts is compared verbatim, never as a number - both parse to the same f64",
560        );
561    }
562
563    /// The ledger is bounded two ways: a conversation keeps only its newest
564    /// rows, and a row past the age is dropped. Both apply when that
565    /// conversation next records a post, so a quiet conversation keeps what
566    /// it had. Without either the table grows for the process's whole life.
567    #[test]
568    fn the_ledger_keeps_only_recent_own_posts() {
569        let dir = tempdir().expect("tempdir");
570        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
571        let now = 1_700_000_000;
572        // Slack ts shape: the same-width seconds prefix, so key order and
573        // time order agree the way they do on the wire.
574        let ts = |offset: u64| format!("{}.000001", now + offset);
575
576        for offset in 0..OWN_POST_KEEP as u64 {
577            record_own_post(&db, "acme", "C1", &ts(offset), now).expect("record");
578        }
579        // Re-recording a row already held - an edit, a share read again -
580        // refreshes it and prunes nothing.
581        record_own_post(&db, "acme", "C1", &ts(5), now).expect("re-record");
582        assert!(
583            own_post(&db, "acme", "C1", &ts(0)).expect("read")
584                && own_post(&db, "acme", "C1", &ts(5)).expect("read"),
585            "a re-recorded ts is not counted twice",
586        );
587
588        record_own_post(&db, "acme", "C1", &ts(OWN_POST_KEEP as u64), now).expect("record");
589        assert!(
590            !own_post(&db, "acme", "C1", &ts(0)).expect("read"),
591            "the oldest row past the count bound is dropped",
592        );
593        assert!(
594            own_post(&db, "acme", "C1", &ts(1)).expect("read"),
595            "exactly one row is dropped: the next-oldest survives",
596        );
597        assert!(
598            own_post(&db, "acme", "C1", &ts(OWN_POST_KEEP as u64)).expect("read"),
599            "the new row is kept",
600        );
601
602        // The age bound is exclusive: a row recorded exactly at the cutoff
603        // is still recent.
604        let old = now.saturating_sub(OWN_POST_MAX_AGE_SECS);
605        record_own_post(&db, "acme", "C1", &ts(9_000), old).expect("record at the bound");
606        record_own_post(&db, "acme", "C1", &ts(9_001), now).expect("record a fresh row");
607        assert!(
608            own_post(&db, "acme", "C1", &ts(9_000)).expect("read"),
609            "a row exactly at the age bound is kept",
610        );
611        record_own_post(&db, "acme", "C1", &ts(9_002), now + 1).expect("record one second on");
612        assert!(
613            !own_post(&db, "acme", "C1", &ts(9_000)).expect("read"),
614            "a row past the age bound is dropped on the next write",
615        );
616    }
617
618    /// `range(prefix..)` is a lower bound, not a filter: without the prefix
619    /// guard a conversation's count bound would count every later-sorting
620    /// conversation's rows and prune them, and those conversations would
621    /// then echo their own posts. The filled conversation deliberately
622    /// sorts AFTER the recording one, or the mutant passes anyway.
623    #[test]
624    fn recording_one_conversation_never_prunes_another() {
625        let dir = tempdir().expect("tempdir");
626        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
627        let now = 1_700_000_000;
628        let ts = |offset: u64| format!("{}.000001", now + offset);
629
630        for offset in 0..OWN_POST_KEEP as u64 {
631            record_own_post(&db, "acme", "C2", &ts(offset), now).expect("record");
632        }
633        record_own_post(&db, "acme", "C1", &ts(9_000), now).expect("record");
634
635        assert!(
636            own_post(&db, "acme", "C2", &ts(0)).expect("read"),
637            "another conversation's oldest row survives the write",
638        );
639        assert!(
640            own_post(&db, "acme", "C1", &ts(9_000)).expect("read"),
641            "and the recording conversation keeps its row",
642        );
643    }
644
645    #[test]
646    fn removing_a_thread_row_removes_only_that_row() {
647        let dir = tempdir().expect("tempdir");
648        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
649
650        let record = thread_record("100.1", &[("forge", None)]);
651        set_thread(&db, "acme", "C1", "100.000001", &record).expect("set");
652        set_thread(&db, "acme", "C1", "200.000002", &record).expect("set");
653
654        assert!(remove_thread(&db, "acme", "C1", "100.000001").expect("remove"));
655        let left = threads_for(&db, "acme", "C1").expect("list");
656        assert_eq!(left.len(), 1, "one row left");
657        assert_eq!(left[0].0, "200.000002", "and it is not the removed one");
658        assert!(!remove_thread(&db, "acme", "C1", "100.000001").expect("remove again"));
659    }
660}