use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use ikigai_core::{Error, Fallback, Space};
use ikigai_store::DurableStore;
use crate::config;
static STORE_SPACE: OnceLock<Option<Arc<dyn Space>>> = OnceLock::new();
static PATH_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
pub fn set_store_path(dir: PathBuf) {
*PATH_OVERRIDE.lock().expect("store path lock") = Some(dir);
}
pub(crate) fn setup() -> Option<Arc<dyn Space>> {
STORE_SPACE.get_or_init(build).clone()
}
fn build() -> Option<Arc<dyn Space>> {
let path = path()?;
match DurableStore::open(&path) {
Ok(store) => Some(Arc::new(Fallback::new(vec![
Arc::new(ikigai_store::space(store)) as Arc<dyn Space>,
Arc::new(ikigai_ledger::space()) as Arc<dyn Space>,
]))),
Err(e @ Error::Unavailable(_)) => {
if let Some(message) = refusal(&path, &e, &mount_lines()) {
eprintln!("{message}");
}
None
}
Err(e) => panic!(
"ikigai: the durable store at {} cannot be opened: {e} — refusing to run \
without it. Fix the path or its permissions, or point this host at another \
directory with `path` in {}/store.toml.",
path.display(),
config_home_display()
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Authority {
Read,
Write,
Delete,
Purge,
}
pub fn grants_for(ledger: &str, authority: Authority) -> Result<Vec<String>, Error> {
let ledger = ikigai_ledger::Ledger::parse(ledger)?;
let graph = ledger.graph();
let mut grants = vec![ledger.cap_read(), ikigai_store::cap_read_graph(&graph)];
if authority == Authority::Read {
return Ok(grants);
}
grants.push(ledger.cap_write());
grants.push(ikigai_store::cap_write_graph(&graph));
if authority == Authority::Write {
return Ok(grants);
}
grants.push(ledger.cap_delete());
grants.push(ikigai_store::cap_write_graph(&ledger.deleted_graph()));
if authority == Authority::Delete {
return Ok(grants);
}
grants.push(ledger.cap_purge());
Ok(grants)
}
fn path() -> Option<PathBuf> {
if let Some(dir) = PATH_OVERRIDE.lock().expect("store path lock").clone() {
return Some(dir);
}
#[cfg(test)]
{
None
}
#[cfg(not(test))]
{
if !enabled() {
return None;
}
let config =
ikigai_store::StoreConfig::load(Some(crate::instance_name())).unwrap_or_else(|e| {
panic!(
"ikigai: `store` is on for this instance but the store configuration \
cannot be read: {e}"
)
});
Some(config.path)
}
}
#[cfg(not(test))]
fn enabled() -> bool {
let instance = crate::instance_name();
enabled_in(
config::get(&format!("{instance}.store")),
config::get("store"),
&config::scoping_instances("store"),
)
}
fn enabled_in(scoped: Option<String>, unscoped: Option<String>, scoping: &[String]) -> bool {
if scoping.is_empty() {
return truthy("store", unscoped.as_deref());
}
assert!(
unscoped.is_none(),
"ikigai: `store` is scoped to {} but the config also has an unscoped `store` \
line — every process honours an unscoped line, so this would put a second \
process on the dataset's exclusive lock. Scope ALL of them \
(`<instance>.store = true`), designate ONE serving instance, and point every \
other process at it: mount = \"prefer urn:iki:store:=<serve socket>\".",
scoping
.iter()
.map(|i| format!("`{i}.store`"))
.collect::<Vec<_>>()
.join(", ")
);
truthy("store", scoped.as_deref())
}
fn truthy(key: &str, value: Option<&str>) -> bool {
match value {
None => false,
Some("true") => true,
Some("false") => false,
Some(other) => panic!(
"ikigai: `{key} = {other}` is neither true nor false — fix the config (a \
value nobody can read would silently mean off, which looks the same as a \
setting that is working)"
),
}
}
#[cfg(not(test))]
fn mount_lines() -> Vec<String> {
config::all("mount")
}
#[cfg(test)]
fn mount_lines() -> Vec<String> {
Vec::new()
}
fn some_mount_claims(lines: &[String], family: &str) -> bool {
lines.iter().any(|line| {
line.split('=')
.next()
.and_then(|head| head.split_whitespace().next_back())
.is_some_and(|prefix| family.starts_with(prefix))
})
}
fn refusal(path: &Path, e: &Error, mounts: &[String]) -> Option<String> {
let store = some_mount_claims(mounts, STORE_PREFIX);
let ledger = some_mount_claims(mounts, LEDGER_PREFIX);
if store && ledger {
return None;
}
let fix = match (store, ledger) {
(true, true) => unreachable!("returned above"),
(true, false) => format!(
"fix: a mount already reaches {STORE_PREFIX}* but NOTHING reaches \
{LEDGER_PREFIX}* — a mount claims one prefix, so the ledger needs its own \
line. Add mount = \"prefer {LEDGER_PREFIX}=<the holder's socket>\" to \
{}/config.toml.",
config_home_display()
),
(false, true) => format!(
"fix: a mount already reaches {LEDGER_PREFIX}* but NOTHING reaches \
{STORE_PREFIX}* — and the ledger owns no bytes, so it will fail on the \
store underneath it. Add mount = \"prefer {STORE_PREFIX}=<the holder's \
socket>\" to {}/config.toml.",
config_home_display()
),
(false, false) => format!(
"fix: this is topology, not a retry. Let ONE process hold the dataset and \
resolve through it — mount = \"prefer {STORE_PREFIX}=<its socket>\" in \
{}/config.toml, and a SECOND line for {LEDGER_PREFIX} (a mount matches one \
prefix, so the ledger needs its own). See docs/durable-store.md.",
config_home_display()
),
};
Some(format!(
"ikigai: {STORE_PREFIX}* and {LEDGER_PREFIX}* are NOT bound here — the durable \
store at {} is held by another process, and RocksDB permits one writer per \
directory.\n \
{fix}\n \
underlying: {e}",
path.display(),
))
}
const STORE_PREFIX: &str = "urn:iki:store:";
const LEDGER_PREFIX: &str = "urn:iki:ledger:";
fn config_home_display() -> String {
config::config_home()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/ikigai".to_string())
}
#[cfg(test)]
mod tests {
use super::{enabled_in, grants_for, refusal, truthy, Authority};
use ikigai_core::Error;
use std::path::Path;
#[test]
fn the_write_grant_names_the_ledger_and_its_graph_and_nothing_broader() {
let grants = grants_for("acme", Authority::Write).expect("a valid ledger name");
assert_eq!(
grants,
vec![
"urn:cap:ledger:read:acme",
"urn:cap:store:read:graph:urn:iki:ledger:graph:acme",
"urn:cap:ledger:write:acme",
"urn:cap:store:write:graph:urn:iki:ledger:graph:acme",
]
);
assert!(
!grants.iter().any(|g| g == ikigai_store::CAP_WRITE),
"the broad store write grant is DROP ALL and the narrow door refuses it: {grants:?}"
);
assert!(
!grants.iter().any(|g| g == ikigai_store::CAP_READ),
"{grants:?}"
);
}
#[test]
fn a_delete_grant_carries_the_graveyard_too() {
let grants = grants_for("acme", Authority::Delete).expect("a valid ledger name");
assert!(
grants.contains(&"urn:cap:ledger:delete:acme".to_string()),
"{grants:?}"
);
assert!(
grants.contains(
&"urn:cap:store:write:graph:urn:iki:ledger:graph:acme:deleted".to_string()
),
"the graveyard is a second graph and therefore a second token: {grants:?}"
);
let purge = grants_for("acme", Authority::Purge).expect("a valid ledger name");
assert_eq!(purge.len(), grants.len() + 1, "{purge:?}");
assert!(
purge.contains(&"urn:cap:ledger:purge:acme".to_string()),
"{purge:?}"
);
}
#[test]
fn the_default_ledgers_grants_name_it_explicitly() {
let grants = grants_for("default", Authority::Read).expect("default is a ledger name");
assert_eq!(
grants,
vec![
"urn:cap:ledger:read:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
]
);
}
#[test]
fn a_name_that_cannot_be_a_ledger_is_refused_here() {
assert!(
grants_for("items", Authority::Read).is_err(),
"reserved by the sugar"
);
assert!(
grants_for("Acme", Authority::Read).is_err(),
"case is not a distinction"
);
assert!(
grants_for("a:b", Authority::Read).is_err(),
"would forge a token"
);
}
#[test]
fn unscoped_governs_when_nobody_scopes() {
assert!(enabled_in(None, Some("true".into()), &[]));
assert!(!enabled_in(None, Some("false".into()), &[]));
assert!(!enabled_in(None, None, &[]));
}
#[test]
fn scoped_governs_only_its_instance() {
let scoping = ["serve".to_string()];
assert!(enabled_in(Some("true".into()), None, &scoping));
assert!(!enabled_in(None, None, &scoping));
}
#[test]
#[should_panic(expected = "unscoped")]
fn mixing_scoped_and_unscoped_is_refused() {
enabled_in(
Some("true".into()),
Some("true".into()),
&["serve".to_string()],
);
}
#[test]
#[should_panic(expected = "neither true nor false")]
fn a_third_answer_is_refused() {
truthy("store", Some("yes"));
}
#[test]
fn the_refusal_names_the_path_and_the_fix() {
let text = held(&[]).expect("no mount, so the operator needs the sentence");
assert!(text.contains("/tmp/ikigai-store"), "{text}");
assert!(text.contains("one writer per directory"), "{text}");
assert!(text.contains("prefer urn:iki:store:="), "{text}");
assert!(text.contains("SECOND line for urn:iki:ledger:"), "{text}");
}
#[test]
fn a_mount_over_both_families_says_nothing() {
assert_eq!(
held(&[
"prefer urn:iki:store:=/Users/you/.ikigai/serve.sock".into(),
"prefer urn:iki:ledger:=/Users/you/.ikigai/serve.sock".into(),
]),
None
);
assert_eq!(held(&["prefer urn:iki:=peer:plasma".into()]), None);
assert!(held(&["prefer urn:llm:=peer:plasma".into()]).is_some());
}
#[test]
fn one_mount_of_the_two_names_the_missing_prefix() {
let store_only =
held(&["prefer urn:iki:store:=/x.sock".into()]).expect("half a topology warns");
assert!(
store_only.contains("NOTHING reaches urn:iki:ledger:"),
"{store_only}"
);
assert!(
store_only.contains("prefer urn:iki:ledger:=<the holder's socket>"),
"{store_only}"
);
let ledger_only =
held(&["prefer urn:iki:ledger:=/x.sock".into()]).expect("half a topology warns");
assert!(
ledger_only.contains("NOTHING reaches urn:iki:store:"),
"{ledger_only}"
);
assert!(ledger_only.contains("owns no bytes"), "{ledger_only}");
}
#[test]
fn a_mount_narrower_than_the_family_is_not_an_answer() {
assert!(held(&["prefer urn:iki:store:select=/x.sock".into()]).is_some());
}
fn held(mounts: &[String]) -> Option<String> {
refusal(
Path::new("/tmp/ikigai-store"),
&Error::Unavailable("the store at /tmp/ikigai-store is already held".into()),
mounts,
)
}
}