mod local;
#[cfg(test)]
mod memory;
use std::fmt::Debug;
use std::path::PathBuf;
#[cfg(test)]
use std::sync::Arc;
pub(crate) use local::{LocalContentStore, LocalJournal};
#[cfg(test)]
pub(crate) use memory::InMemoryStore;
use crate::error::Result;
use crate::storage::{CreateOutcome, RemoveOutcome};
#[derive(Clone, Debug)]
pub(crate) enum StoreBackend {
Local(PathBuf),
#[cfg(test)]
Memory(Arc<InMemoryStore>),
}
impl StoreBackend {
#[cfg(test)]
pub(crate) fn memory() -> Self {
StoreBackend::Memory(InMemoryStore::new())
}
pub(crate) fn journal(&self) -> Box<dyn Journal> {
match self {
StoreBackend::Local(store_dir) => Box::new(LocalJournal::new(store_dir)),
#[cfg(test)]
StoreBackend::Memory(store) => Box::new(store.journal()),
}
}
pub(crate) fn content_store(&self) -> Box<dyn ContentStore> {
match self {
StoreBackend::Local(store_dir) => Box::new(LocalContentStore::new(store_dir)),
#[cfg(test)]
StoreBackend::Memory(store) => Box::new(store.content_store()),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct JournalEntry {
pub(crate) key_digest: String,
pub(crate) bytes: Vec<u8>,
}
pub(crate) trait Journal: Debug {
fn create_event_once(&self, idempotency_key: &str, bytes: &[u8]) -> Result<CreateOutcome>;
fn read_event_bytes(&self, idempotency_key: &str) -> Result<Option<Vec<u8>>>;
fn event_exists(&self, idempotency_key: &str) -> Result<bool>;
fn list_event_entries(&self) -> Result<Vec<JournalEntry>>;
fn head_marker(&self) -> Result<u64>;
#[cfg(test)]
fn insert_raw(&self, idempotency_key: &str, bytes: &[u8]) -> Result<()>;
}
pub(crate) trait ContentStore: Debug {
fn put_once(&self, content_ref: &str, bytes: &[u8]) -> Result<CreateOutcome>;
fn get(&self, content_ref: &str) -> Result<Vec<u8>>;
fn get_if_exists(&self, content_ref: &str) -> Result<Option<Vec<u8>>>;
fn remove(&self, content_ref: &str) -> Result<RemoveOutcome>;
fn list(&self, prefix: &str) -> Result<Vec<String>>;
#[cfg(test)]
fn put_raw(&self, content_ref: &str, bytes: &[u8]) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical_hash::sha256_bytes_hex;
fn each_backend() -> Vec<(Option<tempfile::TempDir>, StoreBackend)> {
let root = tempfile::tempdir().unwrap();
let store_dir = root.path().join(".shore/data");
vec![
(Some(root), StoreBackend::Local(store_dir)),
(None, StoreBackend::memory()),
]
}
#[test]
fn create_event_once_is_create_then_already_exists_without_overwriting() {
for (_guard, backend) in each_backend() {
let journal = backend.journal();
let key = "review_initialized:journal:default:work:default";
assert_eq!(
journal.create_event_once(key, b"first").unwrap(),
CreateOutcome::Created
);
assert_eq!(
journal.create_event_once(key, b"second").unwrap(),
CreateOutcome::AlreadyExists
);
assert_eq!(
journal.read_event_bytes(key).unwrap(),
Some(b"first".to_vec())
);
}
}
#[test]
fn journal_read_and_exists_resolve_by_logical_key() {
for (_guard, backend) in each_backend() {
let journal = backend.journal();
let key = "some:idempotency:key";
assert!(!journal.event_exists(key).unwrap());
assert_eq!(journal.read_event_bytes(key).unwrap(), None);
journal.create_event_once(key, b"bytes").unwrap();
assert!(journal.event_exists(key).unwrap());
assert_eq!(
journal.read_event_bytes(key).unwrap(),
Some(b"bytes".to_vec())
);
assert!(!journal.event_exists("absent:key").unwrap());
}
}
#[test]
fn list_event_entries_is_complete_stably_ordered_and_digest_addressed() {
for (_guard, backend) in each_backend() {
let journal = backend.journal();
let keys = ["k:a", "k:b", "k:c"];
for key in keys {
journal.create_event_once(key, key.as_bytes()).unwrap();
}
let first = journal.list_event_entries().unwrap();
let second = journal.list_event_entries().unwrap();
assert_eq!(first.len(), 3);
let first_pairs: Vec<(&str, &[u8])> = first
.iter()
.map(|e| (e.key_digest.as_str(), e.bytes.as_slice()))
.collect();
let second_pairs: Vec<(&str, &[u8])> = second
.iter()
.map(|e| (e.key_digest.as_str(), e.bytes.as_slice()))
.collect();
assert_eq!(
first_pairs, second_pairs,
"the listing is stable across calls"
);
let mut expected_digests: Vec<String> = keys
.iter()
.map(|k| sha256_bytes_hex(k.as_bytes()))
.collect();
expected_digests.sort();
let listed_digests: Vec<String> = first.iter().map(|e| e.key_digest.clone()).collect();
assert_eq!(listed_digests, expected_digests);
}
}
#[test]
fn head_marker_counts_written_events_and_is_monotonic_on_append() {
for (_guard, backend) in each_backend() {
let journal = backend.journal();
assert_eq!(journal.head_marker().unwrap(), 0, "an empty log marks zero");
for key in ["k:a", "k:b", "k:c"] {
journal.create_event_once(key, key.as_bytes()).unwrap();
}
assert_eq!(
journal.head_marker().unwrap(),
3,
"the marker is the count of stored events"
);
journal.create_event_once("k:d", b"d").unwrap();
assert_eq!(
journal.head_marker().unwrap(),
4,
"an append bumps the marker by one"
);
journal.create_event_once("k:d", b"again").unwrap();
assert_eq!(journal.head_marker().unwrap(), 4);
}
}
#[test]
fn head_marker_unchanged_by_an_in_place_envelope_edit() {
for (_guard, backend) in each_backend() {
let journal = backend.journal();
journal.create_event_once("k:a", b"original").unwrap();
journal.create_event_once("k:b", b"original").unwrap();
let before = journal.head_marker().unwrap();
journal.insert_raw("k:a", b"edited-in-place").unwrap();
assert_eq!(journal.head_marker().unwrap(), before);
}
}
#[test]
fn content_store_round_trips_bytes_and_dedups_a_second_put() {
for (_guard, backend) in each_backend() {
let content = backend.content_store();
let content_ref = "artifacts/objects/abc.json";
assert_eq!(
content.put_once(content_ref, b"blob").unwrap(),
CreateOutcome::Created
);
assert_eq!(
content.put_once(content_ref, b"other").unwrap(),
CreateOutcome::AlreadyExists
);
assert_eq!(content.get(content_ref).unwrap(), b"blob");
assert_eq!(
content.get_if_exists(content_ref).unwrap(),
Some(b"blob".to_vec())
);
assert_eq!(
content
.get_if_exists("artifacts/objects/missing.json")
.unwrap(),
None
);
}
}
#[test]
fn content_store_remove_is_removed_then_missing() {
for (_guard, backend) in each_backend() {
let content = backend.content_store();
let content_ref = "artifacts/notes/def.json";
content.put_once(content_ref, b"body").unwrap();
assert_eq!(content.remove(content_ref).unwrap(), RemoveOutcome::Removed);
assert_eq!(content.remove(content_ref).unwrap(), RemoveOutcome::Missing);
}
}
#[test]
fn content_store_list_returns_store_relative_refs_in_order() {
for (_guard, backend) in each_backend() {
let content = backend.content_store();
content.put_once("artifacts/objects/b.json", b"x").unwrap();
content.put_once("artifacts/objects/a.json", b"y").unwrap();
assert_eq!(
content.list("artifacts/objects").unwrap(),
vec![
"artifacts/objects/a.json".to_owned(),
"artifacts/objects/b.json".to_owned(),
]
);
assert_eq!(
content.list("artifacts/notes").unwrap(),
Vec::<String>::new(),
"a missing prefix lists as empty"
);
}
}
}