1use std::collections::{BTreeMap, HashSet};
4use std::ffi::OsString;
5use std::io;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10pub use jiandu_memory::memory_store::{
11 count_chars, normalize_retrieval_terms, normalize_tags, render_memory_freshness_note,
12 summary_json, truncate_chars, BlobScanItem, BlobScanReport, DreamReadResult, DreamSnapshot,
13 DuplicateCluster, DuplicateScanReport, DurableMemoryDocument, DurableMemoryStatus,
14 DurableMemoryType, FreshnessKind, MemoryConsolidateResult, MemoryContradictionResult,
15 MemoryDuplicateCandidate, MemoryMergeResult, MemoryPurgeResult, MemoryQueryOptions,
16 MemoryQueryResult, MemoryRecallCandidate, MemoryRecallOptions, MemoryRetrievalInput,
17 MemoryScope, MemorySplitPiece, MemorySplitResult, SessionState, TemporalGranularity,
18 DEFAULT_QUERY_LIMIT, DEFAULT_SESSION_TOPIC, MAX_EXPLICIT_MEMORY_ENTITIES,
19 MAX_EXPLICIT_MEMORY_KEYWORDS, MAX_MAX_CHARS, MAX_MEMORY_ENTITIES, MAX_MEMORY_ID_LEN,
20 MAX_MEMORY_KEYWORDS, MAX_MEMORY_QUERY_CHARS, MAX_MEMORY_TAGS, MAX_MEMORY_TAG_CHARS,
21 MAX_MEMORY_TITLE_LEN, MAX_QUERY_LIMIT, MAX_RETRIEVAL_TERM_CHARS,
22};
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct MemoryInspectResult {
27 pub scope: MemoryScope,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub project_key: Option<String>,
30 pub total_memories: usize,
31 #[serde(default)]
32 pub by_type: BTreeMap<String, usize>,
33 #[serde(default)]
34 pub by_status: BTreeMap<String, usize>,
35 #[serde(default)]
36 pub recent_ids: Vec<String>,
37 #[serde(default)]
38 pub view_files: Vec<String>,
39 #[serde(default)]
40 pub index_files: Vec<String>,
41 #[serde(default)]
42 pub state_files: Vec<String>,
43 #[serde(default)]
44 pub stale_candidate_count: usize,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub last_reindex_at: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub last_dream_at: Option<String>,
49 #[serde(default)]
50 pub topic_paths: Vec<String>,
51}
52
53pub const BAMBOO_JIANDU_DATA_DIR_ENV: &str = "BAMBOO_JIANDU_DATA_DIR";
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum JianduDataRoot {
63 CanonicalDefault,
64 Explicit(PathBuf),
65}
66
67impl JianduDataRoot {
68 pub fn mode(&self) -> &'static str {
69 match self {
70 Self::CanonicalDefault => "default",
71 Self::Explicit(_) => "explicit",
72 }
73 }
74
75 pub fn into_path(self) -> PathBuf {
76 match self {
77 Self::CanonicalDefault => MemoryStore::default_data_dir(),
78 Self::Explicit(root) => root,
79 }
80 }
81}
82
83pub fn resolve_jiandu_data_root(explicit: Option<OsString>) -> Result<JianduDataRoot, String> {
87 let Some(explicit) = explicit else {
88 return Ok(JianduDataRoot::CanonicalDefault);
89 };
90 if explicit.is_empty() {
91 return Err(format!(
92 "{BAMBOO_JIANDU_DATA_DIR_ENV} must be a non-empty absolute path when set"
93 ));
94 }
95
96 let root = PathBuf::from(explicit);
97 if !root.is_absolute() {
98 return Err(format!(
99 "{BAMBOO_JIANDU_DATA_DIR_ENV} must be an absolute path when set"
100 ));
101 }
102 Ok(JianduDataRoot::Explicit(root))
103}
104
105#[derive(Debug, Clone)]
108pub struct MemoryStore {
109 store: jiandu_memory::memory_store::MemoryStore,
110}
111
112impl Default for MemoryStore {
113 fn default() -> Self {
114 Self::with_defaults()
115 }
116}
117
118impl MemoryStore {
119 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
121 Self {
122 store: jiandu_memory::memory_store::MemoryStore::new(data_dir),
123 }
124 }
125
126 pub fn with_defaults() -> Self {
128 Self::new(Self::default_data_dir())
129 }
130
131 pub fn default_data_dir() -> PathBuf {
133 dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"))
134 }
135
136 pub fn for_project(&self, project_id: &bamboo_domain::ProjectId) -> Self {
138 let project_id = jiandu_memory::ProjectId::parse(project_id.as_str().to_owned())
139 .expect("Bamboo ProjectId must satisfy Jiandu's identical path-safe contract");
140 Self {
141 store: self.store.for_project(&project_id),
142 }
143 }
144
145 pub async fn read_session_topic(
146 &self,
147 session_id: &str,
148 topic: &str,
149 ) -> io::Result<Option<String>> {
150 self.store.read_session_topic(session_id, topic).await
151 }
152
153 pub async fn write_session_topic(
154 &self,
155 session_id: &str,
156 topic: &str,
157 content: &str,
158 ) -> io::Result<PathBuf> {
159 self.store
160 .write_session_topic(session_id, topic, content)
161 .await
162 }
163
164 pub async fn delete_session_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
165 self.store.delete_session_topic(session_id, topic).await
166 }
167
168 pub async fn list_session_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
169 self.store.list_session_topics(session_id).await
170 }
171
172 pub async fn read_session_topics_with_content(
173 &self,
174 session_id: &str,
175 ) -> io::Result<Vec<(String, String)>> {
176 self.store
177 .read_session_topics_with_content(session_id)
178 .await
179 }
180
181 pub async fn read_session_state(&self, session_id: &str) -> io::Result<SessionState> {
182 self.store.read_session_state(session_id).await
183 }
184
185 pub async fn mark_session_extracted(
186 &self,
187 session_id: &str,
188 extracted_at: &str,
189 ) -> io::Result<()> {
190 self.store
191 .mark_session_extracted(session_id, extracted_at)
192 .await
193 }
194
195 pub async fn read_memory_view(
196 &self,
197 scope: MemoryScope,
198 project_key: Option<&str>,
199 ) -> io::Result<Option<String>> {
200 self.store.read_memory_view(scope, project_key).await
201 }
202
203 #[allow(clippy::too_many_arguments)]
204 pub async fn query_scope(
205 &self,
206 scope: MemoryScope,
207 project_key: Option<&str>,
208 query: Option<&str>,
209 filter_types: Option<&HashSet<DurableMemoryType>>,
210 filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
211 filter_granularity: Option<&HashSet<TemporalGranularity>>,
212 options: &MemoryQueryOptions,
213 ) -> io::Result<MemoryQueryResult> {
214 self.store
215 .query_scope(
216 scope,
217 project_key,
218 query,
219 filter_types,
220 filter_statuses,
221 filter_granularity,
222 options,
223 )
224 .await
225 }
226
227 pub async fn inspect_scope(
228 &self,
229 scope: MemoryScope,
230 project_key: Option<&str>,
231 ) -> io::Result<MemoryInspectResult> {
232 let result = self.store.inspect_scope(scope, project_key).await?;
233 let last_dream_at = if scope == MemoryScope::Session {
234 None
235 } else {
236 self.store
237 .read_dream_snapshot(scope, project_key)
238 .await?
239 .snapshot
240 .map(|snapshot| snapshot.generated_at)
241 };
242
243 Ok(MemoryInspectResult {
244 scope: result.scope,
245 project_key: result.project_key,
246 total_memories: result.total_memories,
247 by_type: result.by_type,
248 by_status: result.by_status,
249 recent_ids: result.recent_ids,
250 view_files: result.view_files,
251 index_files: result.index_files,
252 state_files: result.state_files,
253 stale_candidate_count: result.stale_candidate_count,
254 last_reindex_at: result.last_reindex_at,
255 last_dream_at,
256 topic_paths: result.topic_paths,
257 })
258 }
259
260 pub async fn get_memory(
261 &self,
262 id: &str,
263 preferred_project_key: Option<&str>,
264 ) -> io::Result<Option<DurableMemoryDocument>> {
265 self.store.get_memory(id, preferred_project_key).await
266 }
267
268 #[allow(clippy::too_many_arguments)]
269 pub async fn write_memory(
270 &self,
271 scope: MemoryScope,
272 project_key: Option<&str>,
273 r#type: DurableMemoryType,
274 title: &str,
275 content: &str,
276 tags: &[String],
277 session_id: Option<&str>,
278 actor: &str,
279 allow_merge_if_similar: bool,
280 granularity: Option<TemporalGranularity>,
281 ) -> io::Result<DurableMemoryDocument> {
282 self.store
283 .write_memory(
284 scope,
285 project_key,
286 r#type,
287 title,
288 content,
289 tags,
290 session_id,
291 actor,
292 allow_merge_if_similar,
293 granularity,
294 )
295 .await
296 }
297
298 #[allow(clippy::too_many_arguments)]
299 pub async fn write_memory_with_retrieval(
300 &self,
301 scope: MemoryScope,
302 project_key: Option<&str>,
303 r#type: DurableMemoryType,
304 title: &str,
305 content: &str,
306 tags: &[String],
307 retrieval: &MemoryRetrievalInput,
308 session_id: Option<&str>,
309 actor: &str,
310 allow_merge_if_similar: bool,
311 granularity: Option<TemporalGranularity>,
312 ) -> io::Result<DurableMemoryDocument> {
313 self.store
314 .write_memory_with_retrieval(
315 scope,
316 project_key,
317 r#type,
318 title,
319 content,
320 tags,
321 retrieval,
322 session_id,
323 actor,
324 allow_merge_if_similar,
325 granularity,
326 )
327 .await
328 }
329
330 pub async fn archive_memory(
331 &self,
332 id: &str,
333 preferred_project_key: Option<&str>,
334 mode: DurableMemoryStatus,
335 reason: Option<&str>,
336 ) -> io::Result<Option<DurableMemoryDocument>> {
337 self.store
338 .archive_memory(id, preferred_project_key, mode, reason)
339 .await
340 }
341
342 pub async fn split_memory(
343 &self,
344 id: &str,
345 preferred_project_key: Option<&str>,
346 pieces: &[MemorySplitPiece],
347 session_id: Option<&str>,
348 actor: &str,
349 ) -> io::Result<Option<MemorySplitResult>> {
350 self.store
351 .split_memory(id, preferred_project_key, pieces, session_id, actor)
352 .await
353 }
354
355 pub async fn split_memory_with_retrieval(
356 &self,
357 id: &str,
358 preferred_project_key: Option<&str>,
359 pieces: &[MemorySplitPiece],
360 retrieval: &[MemoryRetrievalInput],
361 session_id: Option<&str>,
362 actor: &str,
363 ) -> io::Result<Option<MemorySplitResult>> {
364 self.store
365 .split_memory_with_retrieval(
366 id,
367 preferred_project_key,
368 pieces,
369 retrieval,
370 session_id,
371 actor,
372 )
373 .await
374 }
375
376 #[allow(clippy::too_many_arguments)]
377 pub async fn find_duplicate_candidates(
378 &self,
379 scope: MemoryScope,
380 project_key: Option<&str>,
381 r#type: Option<DurableMemoryType>,
382 title: &str,
383 content: &str,
384 tags: &[String],
385 limit: usize,
386 ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
387 self.store
388 .find_duplicate_candidates(scope, project_key, r#type, title, content, tags, limit)
389 .await
390 }
391
392 #[allow(clippy::too_many_arguments)]
393 pub async fn find_duplicate_candidates_with_retrieval(
394 &self,
395 scope: MemoryScope,
396 project_key: Option<&str>,
397 r#type: Option<DurableMemoryType>,
398 title: &str,
399 content: &str,
400 tags: &[String],
401 retrieval: &MemoryRetrievalInput,
402 limit: usize,
403 ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
404 self.store
405 .find_duplicate_candidates_with_retrieval(
406 scope,
407 project_key,
408 r#type,
409 title,
410 content,
411 tags,
412 retrieval,
413 limit,
414 )
415 .await
416 }
417
418 pub async fn scan_blob_candidates(
419 &self,
420 scope: MemoryScope,
421 project_key: Option<&str>,
422 min_appended_sections: usize,
423 limit: usize,
424 ) -> io::Result<BlobScanReport> {
425 self.store
426 .scan_blob_candidates(scope, project_key, min_appended_sections, limit)
427 .await
428 }
429
430 pub async fn scan_duplicate_clusters(
431 &self,
432 scope: MemoryScope,
433 project_key: Option<&str>,
434 min_score: f64,
435 max_members_per_cluster: usize,
436 limit: usize,
437 ) -> io::Result<DuplicateScanReport> {
438 self.store
439 .scan_duplicate_clusters(
440 scope,
441 project_key,
442 min_score,
443 max_members_per_cluster,
444 limit,
445 )
446 .await
447 }
448
449 pub async fn consolidate_memories(
450 &self,
451 ids: &[String],
452 preferred_project_key: Option<&str>,
453 merged: &MemorySplitPiece,
454 session_id: Option<&str>,
455 actor: &str,
456 ) -> io::Result<Option<MemoryConsolidateResult>> {
457 self.store
458 .consolidate_memories(ids, preferred_project_key, merged, session_id, actor)
459 .await
460 }
461
462 pub async fn consolidate_memories_with_retrieval(
463 &self,
464 ids: &[String],
465 preferred_project_key: Option<&str>,
466 merged: &MemorySplitPiece,
467 retrieval: &MemoryRetrievalInput,
468 session_id: Option<&str>,
469 actor: &str,
470 ) -> io::Result<Option<MemoryConsolidateResult>> {
471 self.store
472 .consolidate_memories_with_retrieval(
473 ids,
474 preferred_project_key,
475 merged,
476 retrieval,
477 session_id,
478 actor,
479 )
480 .await
481 }
482
483 #[allow(clippy::too_many_arguments)]
484 pub async fn purge_memories(
485 &self,
486 scope: MemoryScope,
487 project_key: Option<&str>,
488 filter_types: Option<&HashSet<DurableMemoryType>>,
489 filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
490 filter_granularity: Option<&HashSet<TemporalGranularity>>,
491 mode: DurableMemoryStatus,
492 reason: Option<&str>,
493 ) -> io::Result<MemoryPurgeResult> {
494 self.store
495 .purge_memories(
496 scope,
497 project_key,
498 filter_types,
499 filter_statuses,
500 filter_granularity,
501 mode,
502 reason,
503 )
504 .await
505 }
506
507 #[allow(clippy::too_many_arguments)]
508 pub async fn mark_memory_contradicted(
509 &self,
510 id: &str,
511 preferred_project_key: Option<&str>,
512 contradicted_by_ids: &[String],
513 reason: Option<&str>,
514 session_id: Option<&str>,
515 actor: &str,
516 ) -> io::Result<Option<MemoryContradictionResult>> {
517 self.store
518 .mark_memory_contradicted(
519 id,
520 preferred_project_key,
521 contradicted_by_ids,
522 reason,
523 session_id,
524 actor,
525 )
526 .await
527 }
528
529 #[allow(clippy::too_many_arguments)]
530 pub async fn merge_memory(
531 &self,
532 id: &str,
533 preferred_project_key: Option<&str>,
534 content: &str,
535 tags: &[String],
536 session_id: Option<&str>,
537 actor: &str,
538 source_memory_ids: &[String],
539 ) -> io::Result<Option<MemoryMergeResult>> {
540 self.store
541 .merge_memory(
542 id,
543 preferred_project_key,
544 content,
545 tags,
546 session_id,
547 actor,
548 source_memory_ids,
549 )
550 .await
551 }
552
553 #[allow(clippy::too_many_arguments)]
554 pub async fn merge_memory_with_retrieval(
555 &self,
556 id: &str,
557 preferred_project_key: Option<&str>,
558 content: &str,
559 tags: &[String],
560 retrieval: &MemoryRetrievalInput,
561 session_id: Option<&str>,
562 actor: &str,
563 source_memory_ids: &[String],
564 ) -> io::Result<Option<MemoryMergeResult>> {
565 self.store
566 .merge_memory_with_retrieval(
567 id,
568 preferred_project_key,
569 content,
570 tags,
571 retrieval,
572 session_id,
573 actor,
574 source_memory_ids,
575 )
576 .await
577 }
578
579 pub async fn rebuild_scope(
580 &self,
581 scope: MemoryScope,
582 project_key: Option<&str>,
583 ) -> io::Result<()> {
584 self.store.rebuild_scope(scope, project_key).await
585 }
586
587 pub async fn current_scope_generation(
588 &self,
589 scope: MemoryScope,
590 project_key: Option<&str>,
591 ) -> io::Result<String> {
592 self.store
593 .current_scope_generation(scope, project_key)
594 .await
595 }
596
597 pub async fn read_dream_snapshot(
598 &self,
599 scope: MemoryScope,
600 project_key: Option<&str>,
601 ) -> io::Result<DreamReadResult> {
602 self.store.read_dream_snapshot(scope, project_key).await
603 }
604
605 pub async fn publish_dream_snapshot(
606 &self,
607 scope: MemoryScope,
608 project_key: Option<&str>,
609 source_generation: &str,
610 content: &str,
611 ) -> io::Result<DreamSnapshot> {
612 self.store
613 .publish_dream_snapshot(scope, project_key, source_generation, content)
614 .await
615 }
616
617 pub async fn list_memory_documents(
618 &self,
619 scope: MemoryScope,
620 project_key: Option<&str>,
621 ) -> io::Result<Vec<DurableMemoryDocument>> {
622 self.store.list_memory_documents(scope, project_key).await
623 }
624
625 pub async fn count_scope_memories(
626 &self,
627 scope: MemoryScope,
628 project_key: Option<&str>,
629 ) -> io::Result<usize> {
630 self.store.count_scope_memories(scope, project_key).await
631 }
632
633 pub async fn enforce_scope_capacity(
634 &self,
635 scope: MemoryScope,
636 project_key: Option<&str>,
637 capacity: usize,
638 max_archivals: usize,
639 ) -> io::Result<Vec<String>> {
640 self.store
641 .enforce_scope_capacity(scope, project_key, capacity, max_archivals)
642 .await
643 }
644
645 pub async fn expire_stale_granularity(
646 &self,
647 scope: MemoryScope,
648 project_key: Option<&str>,
649 ) -> io::Result<Vec<String>> {
650 self.store
651 .expire_stale_granularity(scope, project_key)
652 .await
653 }
654}
655
656#[cfg(test)]
657mod data_root_tests {
658 use super::{resolve_jiandu_data_root, JianduDataRoot, BAMBOO_JIANDU_DATA_DIR_ENV};
659 use std::ffi::OsString;
660 use std::path::PathBuf;
661
662 #[test]
663 fn absent_override_selects_the_canonical_default() {
664 assert_eq!(
665 resolve_jiandu_data_root(None).unwrap(),
666 JianduDataRoot::CanonicalDefault
667 );
668 }
669
670 #[test]
671 fn absolute_override_is_preserved_exactly() {
672 let root = std::env::temp_dir().join("bamboo-explicit-jiandu-root");
673 assert!(root.is_absolute());
674 assert_eq!(
675 resolve_jiandu_data_root(Some(root.clone().into_os_string())).unwrap(),
676 JianduDataRoot::Explicit(root)
677 );
678 }
679
680 #[test]
681 fn empty_override_fails_closed() {
682 let error = resolve_jiandu_data_root(Some(OsString::new())).unwrap_err();
683 assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
684 assert!(error.contains("non-empty absolute path"));
685 }
686
687 #[test]
688 fn relative_override_fails_closed() {
689 let error =
690 resolve_jiandu_data_root(Some(OsString::from(PathBuf::from("relative/jiandu"))))
691 .unwrap_err();
692 assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
693 assert!(error.contains("absolute path"));
694 }
695}
696
697pub async fn shortlist_relevant_memories(
700 store: &MemoryStore,
701 project_key: Option<&str>,
702 query: &str,
703 options: &MemoryRecallOptions,
704) -> io::Result<Vec<MemoryRecallCandidate>> {
705 jiandu_memory::memory_store::shortlist_relevant_memories(
706 &store.store,
707 project_key,
708 query,
709 options,
710 )
711 .await
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use tempfile::tempdir;
718
719 async fn write_durable(
720 store: &MemoryStore,
721 scope: MemoryScope,
722 project_key: Option<&str>,
723 title: &str,
724 body: &str,
725 ) -> DurableMemoryDocument {
726 store
727 .write_memory(
728 scope,
729 project_key,
730 if scope == MemoryScope::Project {
731 DurableMemoryType::Project
732 } else {
733 DurableMemoryType::Reference
734 },
735 title,
736 body,
737 &["facade-test".to_string()],
738 Some("session-test"),
739 "facade-test",
740 false,
741 None,
742 )
743 .await
744 .expect("write durable memory")
745 }
746
747 #[test]
748 fn default_root_is_dot_jiandu_under_home() {
749 let expected =
750 dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"));
751 assert_eq!(MemoryStore::default_data_dir(), expected);
752 }
753
754 #[tokio::test]
755 async fn session_global_and_typed_project_round_trip_with_scope_isolation() {
756 let directory = tempdir().expect("tempdir");
757 let store = MemoryStore::new(directory.path());
758
759 store
760 .write_session_topic("session_1", DEFAULT_SESSION_TOPIC, "session note")
761 .await
762 .expect("write session note");
763 assert_eq!(
764 store
765 .read_session_topic("session_1", DEFAULT_SESSION_TOPIC)
766 .await
767 .expect("read session note")
768 .as_deref(),
769 Some("session note")
770 );
771
772 let global = write_durable(
773 &store,
774 MemoryScope::Global,
775 None,
776 "Global decision",
777 "Prefer deterministic lexical memory.",
778 )
779 .await;
780
781 let project_id =
782 bamboo_domain::ProjectId::parse("project_alpha").expect("valid Bamboo ProjectId");
783 let project_store = store.for_project(&project_id);
784 let project = write_durable(
785 &project_store,
786 MemoryScope::Project,
787 Some(project_id.as_str()),
788 "Project decision",
789 "Project alpha uses the Jiandu facade.",
790 )
791 .await;
792
793 assert!(project
794 .path
795 .starts_with(directory.path().join("projects/project_alpha/memory/v1")));
796 assert_eq!(
797 store
798 .get_memory(&global.frontmatter.id, None)
799 .await
800 .expect("read global")
801 .expect("global exists")
802 .body,
803 global.body
804 );
805 assert!(
806 store
807 .get_memory(&project.frontmatter.id, None)
808 .await
809 .expect("unscoped lookup")
810 .is_none(),
811 "unscoped lookup must not scan Projects"
812 );
813 assert!(project_store
814 .get_memory(&project.frontmatter.id, Some(project_id.as_str()))
815 .await
816 .expect("typed Project lookup")
817 .is_some());
818
819 let other_id =
820 bamboo_domain::ProjectId::parse("project_beta").expect("valid Bamboo ProjectId");
821 assert!(store
822 .for_project(&other_id)
823 .get_memory(&project.frontmatter.id, Some(other_id.as_str()))
824 .await
825 .expect("unrelated Project lookup")
826 .is_none());
827 }
828
829 #[tokio::test]
830 async fn inspect_reads_dream_timestamp_from_the_same_scope() {
831 let directory = tempdir().expect("tempdir");
832 let store = MemoryStore::new(directory.path());
833 write_durable(
834 &store,
835 MemoryScope::Global,
836 None,
837 "Global fact",
838 "Global memory remains separate.",
839 )
840 .await;
841
842 let global_generation = store
843 .current_scope_generation(MemoryScope::Global, None)
844 .await
845 .expect("global generation");
846 let global_dream = store
847 .publish_dream_snapshot(
848 MemoryScope::Global,
849 None,
850 &global_generation,
851 "Global orientation",
852 )
853 .await
854 .expect("publish global Dream");
855
856 let project_id =
857 bamboo_domain::ProjectId::parse("project_dream").expect("valid Bamboo ProjectId");
858 let project_store = store.for_project(&project_id);
859 write_durable(
860 &project_store,
861 MemoryScope::Project,
862 Some(project_id.as_str()),
863 "Project fact",
864 "Project memory remains separate.",
865 )
866 .await;
867
868 let before_project_dream = project_store
869 .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
870 .await
871 .expect("inspect Project before Dream");
872 assert_eq!(before_project_dream.last_dream_at, None);
873
874 let project_generation = project_store
875 .current_scope_generation(MemoryScope::Project, Some(project_id.as_str()))
876 .await
877 .expect("Project generation");
878 let project_dream = project_store
879 .publish_dream_snapshot(
880 MemoryScope::Project,
881 Some(project_id.as_str()),
882 &project_generation,
883 "Project orientation",
884 )
885 .await
886 .expect("publish Project Dream");
887
888 assert_eq!(
889 store
890 .inspect_scope(MemoryScope::Global, None)
891 .await
892 .expect("inspect Global")
893 .last_dream_at,
894 Some(global_dream.generated_at)
895 );
896 assert_eq!(
897 project_store
898 .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
899 .await
900 .expect("inspect Project")
901 .last_dream_at,
902 Some(project_dream.generated_at)
903 );
904 }
905
906 #[tokio::test]
907 async fn dream_cold_success_stale_and_failed_cas_preserve_prior_snapshot() {
908 let directory = tempdir().expect("tempdir");
909 let store = MemoryStore::new(directory.path());
910
911 let cold = store
912 .read_dream_snapshot(MemoryScope::Global, None)
913 .await
914 .expect("read cold Dream");
915 assert!(cold.snapshot.is_none());
916 assert!(!cold.stale);
917
918 let initial = store
919 .publish_dream_snapshot(
920 MemoryScope::Global,
921 None,
922 &cold.current_generation,
923 "Initial complete orientation",
924 )
925 .await
926 .expect("publish initial Dream");
927 let fresh = store
928 .read_dream_snapshot(MemoryScope::Global, None)
929 .await
930 .expect("read fresh Dream");
931 assert_eq!(fresh.snapshot.as_ref(), Some(&initial));
932 assert!(!fresh.stale);
933
934 write_durable(
935 &store,
936 MemoryScope::Global,
937 None,
938 "Concurrent fact",
939 "Canonical memory changed after synthesis began.",
940 )
941 .await;
942 let stale = store
943 .read_dream_snapshot(MemoryScope::Global, None)
944 .await
945 .expect("read stale Dream");
946 assert!(stale.stale);
947 assert_eq!(stale.snapshot.as_ref(), Some(&initial));
948
949 let error = store
950 .publish_dream_snapshot(
951 MemoryScope::Global,
952 None,
953 &cold.current_generation,
954 "Must not replace the complete snapshot",
955 )
956 .await
957 .expect_err("stale generation must fail CAS");
958 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
959 assert_eq!(
960 store
961 .read_dream_snapshot(MemoryScope::Global, None)
962 .await
963 .expect("read preserved Dream")
964 .snapshot,
965 Some(initial)
966 );
967 }
968
969 #[tokio::test]
970 async fn rebuild_preserves_a_complete_dream_snapshot() {
971 let directory = tempdir().expect("tempdir");
972 let store = MemoryStore::new(directory.path());
973 write_durable(
974 &store,
975 MemoryScope::Global,
976 None,
977 "Rebuild fact",
978 "Derived indexes can rebuild without replacing Dream.",
979 )
980 .await;
981 let generation = store
982 .current_scope_generation(MemoryScope::Global, None)
983 .await
984 .expect("generation");
985 let dream = store
986 .publish_dream_snapshot(
987 MemoryScope::Global,
988 None,
989 &generation,
990 "Stable complete orientation",
991 )
992 .await
993 .expect("publish Dream");
994
995 store
996 .rebuild_scope(MemoryScope::Global, None)
997 .await
998 .expect("rebuild scope");
999 let after = store
1000 .read_dream_snapshot(MemoryScope::Global, None)
1001 .await
1002 .expect("read Dream after rebuild");
1003 assert!(!after.stale);
1004 assert_eq!(after.snapshot, Some(dream));
1005 }
1006}