106 lines
3.3 KiB
Rust
106 lines
3.3 KiB
Rust
|
|
//! In-memory content store — fast synchronous reads for story markdown.
|
||
|
|
//!
|
||
|
|
//! Backed by a `HashMap<story_id, markdown>` wrapped in a `Mutex`. In
|
||
|
|
//! non-test builds the store lives in a process-global `OnceLock`; in tests
|
||
|
|
//! each thread gets its own isolated copy via a `thread_local!` to avoid
|
||
|
|
//! cross-test pollution.
|
||
|
|
use std::collections::HashMap;
|
||
|
|
use std::sync::{Mutex, OnceLock};
|
||
|
|
|
||
|
|
static CONTENT_STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
thread_local! {
|
||
|
|
/// Per-thread isolated content store used in tests to prevent cross-test pollution.
|
||
|
|
pub(super) static CONTENT_STORE_TL: OnceLock<Mutex<HashMap<String, String>>> = const { OnceLock::new() };
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(not(test))]
|
||
|
|
/// Return a reference to the process-global content store, or `None` if not yet initialised.
|
||
|
|
pub(super) fn get_content_store() -> Option<&'static Mutex<HashMap<String, String>>> {
|
||
|
|
CONTENT_STORE.get()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
/// Return the thread-local content store for tests, falling back to the global store.
|
||
|
|
pub(super) fn get_content_store() -> Option<&'static Mutex<HashMap<String, String>>> {
|
||
|
|
let tl = CONTENT_STORE_TL.with(|lock| {
|
||
|
|
if lock.get().is_some() {
|
||
|
|
Some(lock as *const OnceLock<Mutex<HashMap<String, String>>>)
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
}
|
||
|
|
});
|
||
|
|
if let Some(ptr) = tl {
|
||
|
|
// SAFETY: The thread-local lives as long as the thread, which outlives
|
||
|
|
// any test using it. We only need 'static for the return type.
|
||
|
|
let lock = unsafe { &*ptr };
|
||
|
|
lock.get()
|
||
|
|
} else {
|
||
|
|
CONTENT_STORE.get()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Read the full markdown content of a story from the in-memory store.
|
||
|
|
pub fn read_content(story_id: &str) -> Option<String> {
|
||
|
|
let store = get_content_store()?;
|
||
|
|
let map = store.lock().ok()?;
|
||
|
|
map.get(story_id).cloned()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Write (or overwrite) the full markdown content of a story.
|
||
|
|
///
|
||
|
|
/// Updates the in-memory store immediately.
|
||
|
|
pub fn write_content(story_id: &str, content: &str) {
|
||
|
|
if let Some(store) = get_content_store()
|
||
|
|
&& let Ok(mut map) = store.lock()
|
||
|
|
{
|
||
|
|
map.insert(story_id.to_string(), content.to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove a story's content from the in-memory store.
|
||
|
|
pub fn delete_content(story_id: &str) {
|
||
|
|
if let Some(store) = get_content_store()
|
||
|
|
&& let Ok(mut map) = store.lock()
|
||
|
|
{
|
||
|
|
map.remove(story_id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Ensure the in-memory content store is initialised.
|
||
|
|
///
|
||
|
|
/// Safe to call multiple times — the `OnceLock` is set at most once.
|
||
|
|
pub fn ensure_content_store() {
|
||
|
|
#[cfg(not(test))]
|
||
|
|
{
|
||
|
|
let _ = CONTENT_STORE.set(Mutex::new(HashMap::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
{
|
||
|
|
CONTENT_STORE_TL.with(|lock| {
|
||
|
|
if lock.get().is_none() {
|
||
|
|
let _ = lock.set(Mutex::new(HashMap::new()));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
crate::crdt_state::init_for_test();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Return all story IDs present in the content store.
|
||
|
|
pub fn all_content_ids() -> Vec<String> {
|
||
|
|
match get_content_store() {
|
||
|
|
Some(store) => match store.lock() {
|
||
|
|
Ok(map) => map.keys().cloned().collect(),
|
||
|
|
Err(_) => Vec::new(),
|
||
|
|
},
|
||
|
|
None => Vec::new(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Initialise the content store from a pre-loaded map (used during DB startup).
|
||
|
|
pub(super) fn init_content_store(map: HashMap<String, String>) {
|
||
|
|
let _ = CONTENT_STORE.set(Mutex::new(map));
|
||
|
|
}
|