1use 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
17pub 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
29pub fn list(db: &Db) -> anyhow::Result<Vec<SlackSubscription>> {
31 read_all(db)
32}
33
34pub 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 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 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
72const WATERMARKS: TableDefinition<&str, &str> = TableDefinition::new("slack_watermarks");
76
77fn watermark_key(workspace: &str, conversation: &str) -> String {
80 format!("{workspace}\u{0}{conversation}")
81}
82
83pub 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
99pub 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
112pub 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
125pub 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
145const THREADS: TableDefinition<&str, &str> = TableDefinition::new("slack_threads");
149
150fn thread_key(workspace: &str, conversation: &str, parent_ts: &str) -> String {
152 format!("{workspace}\u{0}{conversation}\u{0}{parent_ts}")
153}
154
155pub 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
175pub 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
194pub 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
223pub 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
253pub 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
270const OWN_POSTS: TableDefinition<&str, u64> = TableDefinition::new("slack_own_posts");
275
276const 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
291pub 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
303pub 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 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 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 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 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 #[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 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 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 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 #[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}