{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[52f5c51c] Add Reddit OAuth linking and make UUID the only account identity.\n\nOAuth providers only attach to a session UUID (first link creates the\nprincipal); linked providers stay private on the account page.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/AGENTS.md b/AGENTS.md\nindex 6e0fd8ebb65d665c9c1438e3275971d62b98fd95..e9cc3173dbeb21ad0fc090ca7b407b027c7820a9 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -35,8 +35,11 @@ Environment variables (defaults in `server/src/state.rs`):\n - `SORTER2_DATA_DIR` — default `./data` (created on startup)\n - `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`\n - `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)\n-- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)\n-- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)\n+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth linking (optional)\n+- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` (or `REDDIT_APP_*`) — Reddit API import + OAuth linking (optional)\n+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` and `/auth/reddit` (tests only)\n+\n+Identity: UUID is canonical. OAuth providers only *link* to a UUID (first link creates the principal). Linked providers are private to the account owner.\n \n Health check: `GET /healthz` → `ok`.\n \ndiff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs\nindex 5906f93b13853421e96a3c37bc9d8202a47842bf..c706ae8045a811e5941f5f6c72da88f42a403a82 100644\n--- a/server/src/auth/mod.rs\n+++ b/server/src/auth/mod.rs\n@@ -1,4 +1,8 @@\n-//! GitHub OAuth login, session cookies, and vote actor resolution.\n+//! OAuth linking, session cookies, and vote actor resolution.\n+//!\n+//! Canonical identity is a UUID. OAuth providers only *link* to that UUID\n+//! (first link creates the principal; later links attach while logged in).\n+//! Which providers are linked is private to the account owner.\n \n pub mod config;\n pub mod identity;\n@@ -22,7 +26,9 @@ use crate::{\n form_template::template_json_compact,\n html::layout,\n state::AppState,\n- storage_schema::{oauth_link_owner, pseudonym_owner, Store, StoreFields},\n+ storage_schema::{\n+ linked_providers_for_uuid, oauth_link_owner, pseudonym_owner, Store, StoreFields,\n+ },\n ui_action::UI_RPC_FIELD,\n };\n \n@@ -53,10 +59,12 @@ fn new_actor_uuid() -> String {\n pub struct LoginQuery {\n #[serde(default)]\n pub return_to: Option,\n+ #[serde(default)]\n+ pub error: Option,\n }\n \n #[derive(Debug, Deserialize)]\n-pub struct GitHubStartQuery {\n+pub struct OAuthStartQuery {\n #[serde(default)]\n pub return_to: Option,\n #[serde(default)]\n@@ -72,15 +80,22 @@ fn return_from_query_or_jar(jar: &CookieJar, query: Option<&str>) -> String {\n .unwrap_or_else(|| \"/\".to_string())\n }\n \n-fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, String)> {\n+/// Available OAuth link targets: `(provider_key, label, start_href)`.\n+fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, &'static str, String)> {\n let mut out = Vec::new();\n+ let enc = urlencoding::encode(return_to);\n if oauth::GitHubConfig::from_env(base_url).is_some() {\n out.push((\n- \"GitHub\",\n- format!(\n- \"/auth/github?return_to={}\",\n- urlencoding::encode(return_to)\n- ),\n+ \"github\",\n+ oauth::provider_label(\"github\"),\n+ format!(\"/auth/github?return_to={enc}\"),\n+ ));\n+ }\n+ if oauth::RedditConfig::from_env(base_url).is_some() {\n+ out.push((\n+ \"reddit\",\n+ oauth::provider_label(\"reddit\"),\n+ format!(\"/auth/reddit?return_to={enc}\"),\n ));\n }\n out\n@@ -125,23 +140,41 @@ fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result Markup {\n+fn login_error_message(code: Option<&str>) -> Option<&'static str> {\n+ match code {\n+ Some(\"oauth_taken\") => {\n+ Some(\"that OAuth account is already linked to a different sorter2 account\")\n+ }\n+ Some(\"oauth_failed\") => Some(\"OAuth failed — try again\"),\n+ _ => None,\n+ }\n+}\n+\n+fn signed_out_body(\n+ providers: &[(&str, &str, String)],\n+ error: Option<&str>,\n+) -> Markup {\n html! {\n main class=\"panel login-page\" {\n section class=\"login-section\" {\n h1 { \"sign in\" }\n- p class=\"muted\" { \"link an account to vote under a lasting alias\" }\n+ p class=\"muted\" {\n+ \"link an OAuth account to create your identity, then claim an alias to vote\"\n+ }\n+ @if let Some(msg) = login_error_message(error) {\n+ p class=\"alias-bad\" data-testid=\"login-error\" { (msg) }\n+ }\n @if providers.is_empty() {\n p class=\"muted\" {\n- \"OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.\"\n+ \"OAuth is not configured. Set GitHub and/or Reddit client credentials.\"\n }\n } @else {\n ul class=\"oauth-provider-list\" {\n- @for (name, href) in providers {\n+ @for (key, label, href) in providers {\n li {\n a href=(href) class=\"btn-primary oauth-provider\"\n- data-testid=(format!(\"oauth-{}\", name.to_lowercase())) {\n- (format!(\"Continue with {name}\"))\n+ data-testid=(format!(\"oauth-{key}\")) {\n+ (format!(\"Link {label}\"))\n }\n }\n }\n@@ -156,7 +189,10 @@ fn signed_out_body(providers: &[(&str, String)]) -> Markup {\n fn account_body(\n actor: &session::SessionActor,\n aliases: &[String],\n- providers: &[(&str, String)],\n+ // Provider keys already linked to this UUID (private).\n+ linked: &[String],\n+ // Providers available to link: not yet attached.\n+ unlinkable: &[(&str, &str, String)],\n claim_forms: Markup,\n ) -> Markup {\n let current = actor.pseudonym.trim();\n@@ -212,16 +248,29 @@ fn account_body(\n (claim_forms)\n }\n \n- @if !providers.is_empty() {\n- section class=\"login-section\" {\n- h2 { \"linked sign-in\" }\n- p class=\"muted small\" { \"sign in again with the same provider to return to this account\" }\n+ section class=\"login-section\" {\n+ h2 { \"linked sign-in\" }\n+ p class=\"muted small\" {\n+ \"private to you — linking more providers raises trust weight without publishing which accounts you use\"\n+ }\n+ @if linked.is_empty() {\n+ p class=\"muted\" data-testid=\"linked-providers-empty\" { \"none yet\" }\n+ } @else {\n+ ul class=\"linked-provider-list\" data-testid=\"linked-providers\" {\n+ @for key in linked {\n+ li data-testid=(format!(\"linked-{key}\")) {\n+ (oauth::provider_label(key))\n+ }\n+ }\n+ }\n+ }\n+ @if !unlinkable.is_empty() {\n ul class=\"oauth-provider-list\" {\n- @for (name, href) in providers {\n+ @for (key, label, href) in unlinkable {\n li {\n a href=(href) class=\"btn-secondary oauth-provider\"\n- data-testid=(format!(\"oauth-relink-{}\", name.to_lowercase())) {\n- (format!(\"Re-link {name}\"))\n+ data-testid=(format!(\"oauth-link-{key}\")) {\n+ (format!(\"Link {label}\"))\n }\n }\n }\n@@ -243,12 +292,21 @@ fn account_body(\n fn login_body(\n session: Option<&session::SessionActor>,\n aliases: &[String],\n- providers: &[(&str, String)],\n+ linked: &[String],\n+ providers: &[(&str, &str, String)],\n claim_forms: Option,\n+ error: Option<&str>,\n ) -> Markup {\n match (session, claim_forms) {\n- (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),\n- _ => signed_out_body(providers),\n+ (Some(actor), Some(forms)) => {\n+ let unlinkable: Vec<_> = providers\n+ .iter()\n+ .filter(|(key, _, _)| !linked.iter().any(|p| p == key))\n+ .cloned()\n+ .collect();\n+ account_body(actor, aliases, linked, &unlinkable, forms)\n+ }\n+ _ => signed_out_body(providers, error),\n }\n }\n \n@@ -268,6 +326,10 @@ pub async fn login_page(\n .as_ref()\n .map(|s| alias_list(db, &s.uuid))\n .unwrap_or_default();\n+ let linked = session\n+ .as_ref()\n+ .map(|s| linked_providers_for_uuid(db, &s.uuid).unwrap_or_default())\n+ .unwrap_or_default();\n let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);\n \n let claim_forms = if session.is_some() {\n@@ -282,7 +344,14 @@ pub async fn login_page(\n } else {\n \"login · sorter2\"\n },\n- login_body(session.as_ref(), &aliases, &providers, claim_forms),\n+ login_body(\n+ session.as_ref(),\n+ &aliases,\n+ &linked,\n+ &providers,\n+ claim_forms,\n+ query.error.as_deref(),\n+ ),\n state.views.get_views(\"/login\"),\n session\n .as_ref()\n@@ -302,7 +371,6 @@ pub async fn alias_page(\n let db = state.projection_store.db();\n let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;\n if session::session_has_pseudonym(&session) {\n- // Already onboarded — manage aliases on the account page.\n return Ok(Redirect::to(\"/login\").into_response());\n }\n \n@@ -331,7 +399,7 @@ pub async fn alias_page(\n pub async fn github_start(\n State(state): State,\n jar: CookieJar,\n- Query(query): Query,\n+ Query(query): Query,\n ) -> Result {\n let cfg = oauth::GitHubConfig::from_env(&base_url_from_env(state.cfg.port))\n .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;\n@@ -342,7 +410,28 @@ pub async fn github_start(\n } else {\n None\n };\n- let url = oauth::authorize_url(&cfg, &state_token, mock_user);\n+ let url = oauth::github_authorize_url(&cfg, &state_token, mock_user);\n+ let jar = jar\n+ .add(session::oauth_state_cookie_value(&state_token))\n+ .add(session::auth_return_cookie_value(&return_to));\n+ Ok((jar, Redirect::temporary(&url)).into_response())\n+}\n+\n+pub async fn reddit_start(\n+ State(state): State,\n+ jar: CookieJar,\n+ Query(query): Query,\n+) -> Result {\n+ let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))\n+ .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;\n+ let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());\n+ let state_token = session::new_oauth_state();\n+ let mock_user = if config::mock_oauth_allowed() {\n+ query.mock_user.as_deref()\n+ } else {\n+ None\n+ };\n+ let url = oauth::reddit_authorize_url(&cfg, &state_token, mock_user);\n let jar = jar\n .add(session::oauth_state_cookie_value(&state_token))\n .add(session::auth_return_cookie_value(&return_to));\n@@ -355,6 +444,13 @@ pub struct OAuthCallbackQuery {\n pub state: String,\n }\n \n+/// Link `provider:provider_id` to a UUID.\n+///\n+/// - Logged in + new provider → attach to session UUID\n+/// - Logged in + already ours → no-op\n+/// - Logged in + owned by someone else → conflict\n+/// - Logged out + known link → resume that UUID\n+/// - Logged out + unknown → create principal + first link\n async fn finish_oauth_login(\n state: &AppState,\n jar: CookieJar,\n@@ -364,11 +460,41 @@ async fn finish_oauth_login(\n let db = state.projection_store.db();\n let return_to = return_from_query_or_jar(&jar, None);\n \n- let uuid = match oauth_link_owner(db, provider, &provider_id)\n- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?\n- {\n- Some(existing) => existing,\n- None => {\n+ let existing_owner = oauth_link_owner(db, provider, &provider_id)\n+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n+\n+ let session_uuid = session::session_id_from_jar(&jar)\n+ .as_deref()\n+ .and_then(|id| session::load_valid_session(db, id))\n+ .map(|s| s.uuid);\n+ let linking_while_logged_in = session_uuid.is_some();\n+\n+ let uuid = match (session_uuid, existing_owner) {\n+ (Some(session_uuid), Some(owner)) if owner == session_uuid => session_uuid,\n+ (Some(_), Some(_)) => {\n+ return Ok((\n+ jar.add(session::clear_oauth_state_cookie()),\n+ \"/login?error=oauth_taken\".into(),\n+ ));\n+ }\n+ (Some(session_uuid), None) => {\n+ let ts = now_ms();\n+ state\n+ .append_identity_events(vec![Event::OauthLinked {\n+ uuid: session_uuid.clone(),\n+ provider: provider.to_string(),\n+ provider_id,\n+ ts,\n+ }])\n+ .await\n+ .map_err(|e| {\n+ tracing::warn!(err = %e, \"oauth link append failed\");\n+ StatusCode::INTERNAL_SERVER_ERROR\n+ })?;\n+ session_uuid\n+ }\n+ (None, Some(owner)) => owner,\n+ (None, None) => {\n let uuid = new_actor_uuid();\n let ts = now_ms();\n state\n@@ -409,6 +535,9 @@ async fn finish_oauth_login(\n \"/login/alias?return_to={}\",\n urlencoding::encode(&return_to)\n )\n+ } else if linking_while_logged_in {\n+ // Additional link while already in an account → stay on account page.\n+ \"/login\".to_string()\n } else {\n return_to\n };\n@@ -434,22 +563,57 @@ pub async fn github_callback(\n .build()\n .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n \n- let token = oauth::exchange_code(&client, &cfg, &query.code)\n+ let token = oauth::github_exchange_code(&client, &cfg, &query.code)\n .await\n .map_err(|e| {\n tracing::warn!(err = %e, \"github oauth token exchange failed\");\n StatusCode::BAD_GATEWAY\n })?;\n- let user = oauth::fetch_user(&client, &cfg.api_base, &token)\n+ let user = oauth::github_fetch_user(&client, &cfg.api_base, &token)\n .await\n .map_err(|e| {\n tracing::warn!(err = %e, \"github user fetch failed\");\n StatusCode::BAD_GATEWAY\n })?;\n \n- let provider = \"github\";\n- let provider_id = oauth::provider_id(&user);\n- let (jar, dest) = finish_oauth_login(&state, jar, provider, provider_id).await?;\n+ let (jar, dest) =\n+ finish_oauth_login(&state, jar, \"github\", oauth::github_provider_id(&user)).await?;\n+ Ok((jar, Redirect::to(&dest)).into_response())\n+}\n+\n+pub async fn reddit_callback(\n+ State(state): State,\n+ jar: CookieJar,\n+ Query(query): Query,\n+) -> Result {\n+ let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))\n+ .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;\n+\n+ let expected_state = session::oauth_state_from_jar(&jar).ok_or(StatusCode::BAD_REQUEST)?;\n+ if expected_state != query.state {\n+ return Err(StatusCode::BAD_REQUEST);\n+ }\n+\n+ let client = Client::builder()\n+ .timeout(std::time::Duration::from_secs(15))\n+ .build()\n+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n+\n+ let token = oauth::reddit_exchange_code(&client, &cfg, &query.code)\n+ .await\n+ .map_err(|e| {\n+ tracing::warn!(err = %e, \"reddit oauth token exchange failed\");\n+ StatusCode::BAD_GATEWAY\n+ })?;\n+ let user = oauth::reddit_fetch_user(&client, &cfg, &token)\n+ .await\n+ .map_err(|e| {\n+ tracing::warn!(err = %e, \"reddit user fetch failed\");\n+ StatusCode::BAD_GATEWAY\n+ })?;\n+\n+ let (jar, dest) =\n+ finish_oauth_login(&state, jar, \"reddit\", oauth::reddit_provider_id(&user)).await?;\n Ok((jar, Redirect::to(&dest)).into_response())\n }\n \ndiff --git a/server/src/auth/oauth.rs b/server/src/auth/oauth.rs\nindex b80078fee0c5acd905b9125d29454e46c4e0d066..369b1527d9032cf312a07819279e0f988ef4a36e 100644\n--- a/server/src/auth/oauth.rs\n+++ b/server/src/auth/oauth.rs\n@@ -1,8 +1,15 @@\n-//! GitHub OAuth (raw reqwest, same style as reddit.rs).\n+//! OAuth providers (GitHub + Reddit). Provider accounts only *link* to a UUID;\n+//! the UUID is the canonical identity. Which providers are linked is private.\n \n use reqwest::Client;\n use serde::Deserialize;\n \n+use crate::reddit::{\n+ default_user_agent, reddit_oauth_api_base, reddit_oauth_token_base,\n+};\n+\n+// ── GitHub ──────────────────────────────────────────────────────────────────\n+\n #[derive(Debug, Clone)]\n pub struct GitHubConfig {\n pub client_id: String,\n@@ -40,7 +47,7 @@ impl GitHubConfig {\n }\n \n #[derive(Debug, Deserialize)]\n-struct TokenResponse {\n+struct GitHubTokenResponse {\n access_token: String,\n }\n \n@@ -50,7 +57,7 @@ pub struct GitHubUser {\n pub login: String,\n }\n \n-pub fn authorize_url(cfg: &GitHubConfig, state: &str, mock_user: Option<&str>) -> String {\n+pub fn github_authorize_url(cfg: &GitHubConfig, state: &str, mock_user: Option<&str>) -> String {\n let mut url = format!(\n \"{}/login/oauth/authorize?client_id={}&redirect_uri={}&scope=read:user&state={}\",\n cfg.oauth_base.trim_end_matches('/'),\n@@ -65,7 +72,7 @@ pub fn authorize_url(cfg: &GitHubConfig, state: &str, mock_user: Option<&str>) -\n url\n }\n \n-pub async fn exchange_code(\n+pub async fn github_exchange_code(\n client: &Client,\n cfg: &GitHubConfig,\n code: &str,\n@@ -90,14 +97,14 @@ pub async fn exchange_code(\n return Err(format!(\"github token HTTP {}\", resp.status()));\n }\n \n- let body: TokenResponse = resp\n+ let body: GitHubTokenResponse = resp\n .json()\n .await\n .map_err(|e| format!(\"github token parse failed: {e}\"))?;\n Ok(body.access_token)\n }\n \n-pub async fn fetch_user(\n+pub async fn github_fetch_user(\n client: &Client,\n api_base: &str,\n access_token: &str,\n@@ -120,10 +127,149 @@ pub async fn fetch_user(\n .map_err(|e| format!(\"github user parse failed: {e}\"))\n }\n \n-pub fn provider_id(user: &GitHubUser) -> String {\n+pub fn github_provider_id(user: &GitHubUser) -> String {\n user.id.to_string()\n }\n \n+// ── Reddit ──────────────────────────────────────────────────────────────────\n+\n+#[derive(Debug, Clone)]\n+pub struct RedditConfig {\n+ pub client_id: String,\n+ pub client_secret: String,\n+ pub redirect_uri: String,\n+ /// Host for `/api/v1/authorize` (www.reddit.com in production).\n+ pub authorize_base: String,\n+ /// Host for `POST /api/v1/access_token`.\n+ pub token_base: String,\n+ /// Host for bearer `GET /api/v1/me` (oauth.reddit.com).\n+ pub api_base: String,\n+ pub user_agent: String,\n+}\n+\n+/// Authorize page base; defaults to the same host as token POSTs.\n+pub fn reddit_authorize_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_AUTHORIZE_BASE\")\n+ .or_else(|_| std::env::var(\"REDDIT_OAUTH_BASE\"))\n+ .unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n+impl RedditConfig {\n+ pub fn from_env(base_url: &str) -> Option {\n+ let client_id = std::env::var(\"REDDIT_CLIENT_ID\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_ID\"))\n+ .ok()?;\n+ let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_SECRET\"))\n+ .ok()?;\n+ if client_id.is_empty() || client_secret.is_empty() {\n+ return None;\n+ }\n+ let base = base_url.trim_end_matches('/');\n+ Some(Self {\n+ client_id,\n+ client_secret,\n+ redirect_uri: format!(\"{base}/auth/reddit/callback\"),\n+ authorize_base: reddit_authorize_base(),\n+ token_base: reddit_oauth_token_base(),\n+ api_base: reddit_oauth_api_base(),\n+ user_agent: default_user_agent(),\n+ })\n+ }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct RedditTokenResponse {\n+ access_token: String,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct RedditUser {\n+ /// Stable id (`t2_…`); never use `name` as identity.\n+ pub id: String,\n+ pub name: String,\n+}\n+\n+pub fn reddit_authorize_url(cfg: &RedditConfig, state: &str, mock_user: Option<&str>) -> String {\n+ let mut url = format!(\n+ \"{}/api/v1/authorize?client_id={}&response_type=code&state={}&redirect_uri={}&duration=temporary&scope=identity\",\n+ cfg.authorize_base.trim_end_matches('/'),\n+ urlencoding::encode(&cfg.client_id),\n+ urlencoding::encode(state),\n+ urlencoding::encode(&cfg.redirect_uri),\n+ );\n+ if let Some(user) = mock_user {\n+ url.push_str(\"&mock_user=\");\n+ url.push_str(&urlencoding::encode(user));\n+ }\n+ url\n+}\n+\n+pub async fn reddit_exchange_code(\n+ client: &Client,\n+ cfg: &RedditConfig,\n+ code: &str,\n+) -> Result {\n+ let resp = client\n+ .post(format!(\n+ \"{}/api/v1/access_token\",\n+ cfg.token_base.trim_end_matches('/')\n+ ))\n+ .header(\"User-Agent\", &cfg.user_agent)\n+ .basic_auth(&cfg.client_id, Some(&cfg.client_secret))\n+ .form(&[\n+ (\"grant_type\", \"authorization_code\"),\n+ (\"code\", code),\n+ (\"redirect_uri\", cfg.redirect_uri.as_str()),\n+ ])\n+ .send()\n+ .await\n+ .map_err(|e| format!(\"reddit token request failed: {e}\"))?;\n+\n+ if !resp.status().is_success() {\n+ let status = resp.status();\n+ let body = resp.text().await.unwrap_or_default();\n+ return Err(format!(\"reddit token HTTP {status}: {body}\"));\n+ }\n+\n+ let body: RedditTokenResponse = resp\n+ .json()\n+ .await\n+ .map_err(|e| format!(\"reddit token parse failed: {e}\"))?;\n+ Ok(body.access_token)\n+}\n+\n+pub async fn reddit_fetch_user(\n+ client: &Client,\n+ cfg: &RedditConfig,\n+ access_token: &str,\n+) -> Result {\n+ let resp = client\n+ .get(format!(\n+ \"{}/api/v1/me\",\n+ cfg.api_base.trim_end_matches('/')\n+ ))\n+ .header(\"User-Agent\", &cfg.user_agent)\n+ .bearer_auth(access_token)\n+ .send()\n+ .await\n+ .map_err(|e| format!(\"reddit user request failed: {e}\"))?;\n+\n+ if !resp.status().is_success() {\n+ return Err(format!(\"reddit user HTTP {}\", resp.status()));\n+ }\n+\n+ resp.json()\n+ .await\n+ .map_err(|e| format!(\"reddit user parse failed: {e}\"))\n+}\n+\n+pub fn reddit_provider_id(user: &RedditUser) -> String {\n+ user.id.clone()\n+}\n+\n+// ── Shared helpers ──────────────────────────────────────────────────────────\n+\n pub fn validate_pseudonym(raw: &str) -> Result {\n let trimmed = raw.trim();\n if trimmed.is_empty() {\n@@ -144,3 +290,12 @@ pub fn validate_pseudonym(raw: &str) -> Result {\n pub fn sanitize_pseudonym(login: &str) -> String {\n validate_pseudonym(login).unwrap_or_else(|_| \"user\".to_string())\n }\n+\n+/// Display name for a provider key (`github` → `GitHub`). Never show provider ids.\n+pub fn provider_label(provider: &str) -> &'static str {\n+ match provider {\n+ \"github\" => \"GitHub\",\n+ \"reddit\" => \"Reddit\",\n+ _ => \"OAuth\",\n+ }\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 84f2565b105fb302241b949af64bd5e49916eab2..0d948af1457504fdbd34d0b14261f65ac58da0ec 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -47,6 +47,8 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/login/alias\", get(crate::auth::alias_page))\n .route(\"/auth/github\", get(crate::auth::github_start))\n .route(\"/auth/github/callback\", get(crate::auth::github_callback))\n+ .route(\"/auth/reddit\", get(crate::auth::reddit_start))\n+ .route(\"/auth/reddit/callback\", get(crate::auth::reddit_callback))\n .route(\"/auth/logout\", post(crate::auth::logout))\n .route(\"/auth/switch\", post(crate::auth::switch_pseudonym))\n .route(\"/ui\", post(crate::api::ui_html::post_ui_html))\ndiff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs\nindex 4fb4b48ee0a53b7f673fae0c9731eed5acc33332..f50458c2ff3c447a3cfd28adb298e6883a2da4e9 100644\n--- a/server/src/projection_apply.rs\n+++ b/server/src/projection_apply.rs\n@@ -4,6 +4,8 @@\n //! child links, recent-vote appends) plus a cursor advance, all committed in\n //! one atomic `DisableWal` batch.\n \n+use std::collections::HashMap;\n+\n use crate::{\n event_log::EventLogError,\n events::{Event, EventRecord},\n@@ -44,6 +46,8 @@ pub fn apply_records(\n let db = projection_store.db();\n let mut batch = db.batch();\n let mut last_seq = 0u64;\n+ // Weight reads must see earlier writes in this same batch.\n+ let mut pending_weights: HashMap = HashMap::new();\n \n for record in records {\n match &record.event {\n@@ -85,6 +89,7 @@ pub fn apply_records(\n ensure_path_writes(&mut batch, &parsed);\n }\n Event::PrincipalCreated { uuid, .. } => {\n+ pending_weights.insert(uuid.clone(), BASE_TRUST_WEIGHT);\n batch.write(\n Store::root()\n .user_weights()\n@@ -112,17 +117,25 @@ pub fn apply_records(\n }\n } else {\n batch.write(Store::root().oauth_links().key(&link_key).set(uuid));\n- let current = Store::root()\n- .user_weights()\n- .key(&uuid.clone())\n- .get(db)\n- .map_err(|e| EventLogError::Apply(e.to_string()))?\n+ let current = pending_weights\n+ .get(uuid)\n+ .copied()\n+ .or_else(|| {\n+ Store::root()\n+ .user_weights()\n+ .key(&uuid.clone())\n+ .get(db)\n+ .ok()\n+ .flatten()\n+ })\n .unwrap_or(BASE_TRUST_WEIGHT);\n+ let next = trust_weight_after_link(current);\n+ pending_weights.insert(uuid.clone(), next);\n batch.write(\n Store::root()\n .user_weights()\n .key(&uuid.clone())\n- .set(&trust_weight_after_link(current)),\n+ .set(&next),\n );\n }\n }\n@@ -205,6 +218,15 @@ mod tests {\n ),\n record(\n 3,\n+ Event::OauthLinked {\n+ uuid: uuid.into(),\n+ provider: \"reddit\".into(),\n+ provider_id: \"t2_abc\".into(),\n+ ts,\n+ },\n+ ),\n+ record(\n+ 4,\n Event::PseudonymClaimed {\n uuid: uuid.into(),\n pseudonym: \"octocat\".into(),\n@@ -219,8 +241,12 @@ mod tests {\n oauth_link_owner(store.db(), \"github\", \"42\").unwrap(),\n Some(uuid.to_string())\n );\n+ assert_eq!(\n+ crate::storage_schema::linked_providers_for_uuid(store.db(), uuid).unwrap(),\n+ vec![\"github\".to_string(), \"reddit\".to_string()]\n+ );\n assert_eq!(resolve_actor_uuid(store.db(), \"octocat\").unwrap(), uuid);\n- assert_eq!(user_trust_weight(store.db(), uuid).unwrap(), 1.5);\n+ assert_eq!(user_trust_weight(store.db(), uuid).unwrap(), 2.0);\n let aliases = Store::root()\n .user_pseudonyms()\n .key(&uuid.to_string())\ndiff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs\nindex b942810afd96d3bf01b7765718303a39be4ef8d2..dac6f8e6080e3f5683c24dbb8861f5a981d456c4 100644\n--- a/server/src/storage_schema.rs\n+++ b/server/src/storage_schema.rs\n@@ -103,6 +103,25 @@ pub fn oauth_link_owner(db: &Db, provider: &str, provider_id: &str) -> durable::\n .get(db)\n }\n \n+/// Provider names linked to a UUID (`github`, `reddit`, …). Private — for the\n+/// account owner's page only; never expose which providers are linked publicly.\n+pub fn linked_providers_for_uuid(db: &Db, uuid: &str) -> durable::Result> {\n+ let mut providers = Vec::new();\n+ for (key, owner) in Store::root().oauth_links().iter(db)? {\n+ if owner != uuid {\n+ continue;\n+ }\n+ let Some((provider, _)) = key.split_once(':') else {\n+ continue;\n+ };\n+ if !providers.iter().any(|p| p == provider) {\n+ providers.push(provider.to_string());\n+ }\n+ }\n+ providers.sort();\n+ Ok(providers)\n+}\n+\n pub const RECENT_VOTES_CAP: u64 = 200;\n \n fn id_key(id: &ItemId) -> String {\ndiff --git a/test/support/harness.clj b/test/support/harness.clj\nindex 4505ece5aa193e826ca61c52f1467d252080d66b..741024aab1d14269e225791189633287980ae2e4 100644\n--- a/test/support/harness.clj\n+++ b/test/support/harness.clj\n@@ -48,12 +48,15 @@\n \"GITHUB_CLIENT_SECRET\" \"test-secret\"\n \"GITHUB_OAUTH_BASE\" (str \"http://127.0.0.1:\" oauth-port)\n \"GITHUB_API_BASE\" (str \"http://127.0.0.1:\" oauth-port)\n+ ;; Reddit import fixtures + Reddit OAuth on reddit-port.\n \"REDDIT_API_BASE\" (str \"http://127.0.0.1:\" reddit-port)\n+ \"REDDIT_CLIENT_ID\" \"test-reddit\"\n+ \"REDDIT_CLIENT_SECRET\" \"test-reddit-secret\"\n \"REDDIT_OAUTH_BASE\" (str \"http://127.0.0.1:\" reddit-port)\n- \"REDDIT_CLIENT_ID\" \"\"\n- \"REDDIT_CLIENT_SECRET\" \"\"\n- \"REDDIT_APP_ID\" \"\"\n- \"REDDIT_APP_SECRET\" \"\"}))\n+ \"REDDIT_OAUTH_AUTHORIZE_BASE\" (str \"http://127.0.0.1:\" reddit-port)\n+ \"REDDIT_OAUTH_TOKEN_BASE\" (str \"http://127.0.0.1:\" reddit-port)\n+ \"REDDIT_OAUTH_API_BASE\" (str \"http://127.0.0.1:\" reddit-port)\n+ \"REDDIT_USER_AGENT\" \"web:sorter2-test:v0 (by /u/test)\"}))\n \n (defn with-auth-servers\n \"Start mock Reddit + mock OAuth + release sorter2-server.\ndiff --git a/test/support/mock_oauth.clj b/test/support/mock_oauth.clj\nindex 909d7a7be8b159ea54a122d69c46af3db3318118..5ba7e9be3cf6d2226648e9609a09ed45f306930f 100644\n--- a/test/support/mock_oauth.clj\n+++ b/test/support/mock_oauth.clj\n@@ -1,5 +1,5 @@\n (ns test.support.mock-oauth\n- \"In-process HTTP stub for GitHub OAuth (authorize, token, /user).\"\n+ \"In-process HTTP stub for GitHub + Reddit OAuth (authorize, token, user).\"\n (:require [clojure.string :as str])\n (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n [java.net InetSocketAddress URLDecoder]))\n@@ -12,11 +12,14 @@\n (URLDecoder/decode (or v \"\") \"UTF-8\"))))\n (str/split query #\"&\"))))\n \n-(defn- parse-mock-user [raw]\n+(defn- parse-mock-user\n+ \"GitHub-style `id:login` (numeric id). Reddit-style `t2_xxx:name`.\"\n+ [raw]\n (let [s (or raw \"1002:newbie\")\n [id login] (str/split s #\":\" 2)]\n- {:id (Long/parseLong id)\n- :login (or login \"newbie\")}))\n+ {:id id\n+ :login (or login \"newbie\")\n+ :numeric? (re-matches #\"\\d+\" id)}))\n \n (defn- send-json [^HttpExchange ex status body]\n (let [bytes (.getBytes body \"UTF-8\")]\n@@ -31,9 +34,10 @@\n (.sendResponseHeaders ex 302 -1)\n (.close (.getResponseBody ex)))\n \n-(defn- read-form-code [^HttpExchange ex]\n+(defn- read-form [^HttpExchange ex]\n (let [body (slurp (.getInputStream ex))]\n- (query-param body \"code\")))\n+ {:code (query-param body \"code\")\n+ :grant (query-param body \"grant_type\")}))\n \n (defn- bearer-token [^HttpExchange ex]\n (some-> (.getRequestHeaders ex)\n@@ -44,8 +48,18 @@\n (when (str/starts-with? token \"mock:\")\n (parse-mock-user (subs token 5))))\n \n+(defn- authorize-redirect [exchange query]\n+ (let [redirect-uri (query-param query \"redirect_uri\")\n+ state (query-param query \"state\")\n+ mock-user (query-param query \"mock_user\")\n+ user (parse-mock-user mock-user)\n+ code (str \"mock:\" (:id user) \":\" (:login user))\n+ loc (str redirect-uri \"?code=\" (java.net.URLEncoder/encode code \"UTF-8\")\n+ \"&state=\" (java.net.URLEncoder/encode state \"UTF-8\"))]\n+ (send-redirect exchange loc)))\n+\n (defn start-mock-oauth\n- \"Start mock GitHub OAuth on `port`. Returns a zero-arg `stop` function.\"\n+ \"Start mock GitHub + Reddit OAuth on `port`. Returns a zero-arg `stop` function.\"\n [port]\n (let [server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n handler\n@@ -53,28 +67,45 @@\n (handle [^HttpExchange exchange]\n (let [uri (.getRequestURI exchange)\n path (.getPath uri)\n- query (.getQuery uri)]\n+ query (.getQuery uri)\n+ method (.getRequestMethod exchange)]\n (cond\n+ ;; GitHub authorize\n (str/ends-with? path \"/login/oauth/authorize\")\n- (let [redirect-uri (query-param query \"redirect_uri\")\n- state (query-param query \"state\")\n- mock-user (query-param query \"mock_user\")\n- user (parse-mock-user mock-user)\n- code (str \"mock:\" (:id user) \":\" (:login user))\n- loc (str redirect-uri \"?code=\" (java.net.URLEncoder/encode code \"UTF-8\")\n- \"&state=\" (java.net.URLEncoder/encode state \"UTF-8\"))]\n- (send-redirect exchange loc))\n+ (authorize-redirect exchange query)\n+\n+ ;; Reddit authorize\n+ (str/ends-with? path \"/api/v1/authorize\")\n+ (authorize-redirect exchange query)\n \n- (str/ends-with? path \"/login/oauth/access_token\")\n- (let [code (or (read-form-code exchange) \"mock:1002:newbie\")]\n+ ;; GitHub token\n+ (and (= method \"POST\") (str/ends-with? path \"/login/oauth/access_token\"))\n+ (let [code (or (:code (read-form exchange)) \"mock:1002:newbie\")]\n (send-json exchange 200 (str \"{\\\"access_token\\\":\\\"\" code \"\\\",\\\"token_type\\\":\\\"bearer\\\"}\")))\n \n+ ;; Reddit token (client_credentials for import + authorization_code for login)\n+ (and (= method \"POST\") (str/ends-with? path \"/api/v1/access_token\"))\n+ (let [form (read-form exchange)\n+ grant (or (:grant form) \"\")\n+ code (or (:code form) \"mock:t2_test:redditor\")]\n+ (if (= grant \"client_credentials\")\n+ (send-json exchange 200 \"{\\\"access_token\\\":\\\"app-token\\\",\\\"token_type\\\":\\\"bearer\\\",\\\"expires_in\\\":3600}\")\n+ (send-json exchange 200 (str \"{\\\"access_token\\\":\\\"\" code \"\\\",\\\"token_type\\\":\\\"bearer\\\",\\\"expires_in\\\":3600}\"))))\n+\n+ ;; GitHub user\n (= path \"/user\")\n (let [token (bearer-token exchange)\n- user (or (parse-token-user token) {:id 1002 :login \"newbie\"})]\n+ user (or (parse-token-user token) {:id \"1002\" :login \"newbie\" :numeric? true})]\n (send-json exchange 200\n (str \"{\\\"id\\\":\" (:id user) \",\\\"login\\\":\\\"\" (:login user) \"\\\"}\")))\n \n+ ;; Reddit /api/v1/me\n+ (str/ends-with? path \"/api/v1/me\")\n+ (let [token (bearer-token exchange)\n+ user (or (parse-token-user token) {:id \"t2_test\" :login \"redditor\"})]\n+ (send-json exchange 200\n+ (str \"{\\\"id\\\":\\\"\" (:id user) \"\\\",\\\"name\\\":\\\"\" (:login user) \"\\\"}\")))\n+\n :else\n (send-json exchange 404 \"{\\\"error\\\":\\\"not found\\\"}\")))))]\n (.createContext server \"/\" handler)\ndiff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj\nindex 5efa92db3e1f79b8f423a2f1adcbda959123c1ad..a630cf0938722193e9af88382d60e777ff371be4 100644\n--- a/test/support/mock_reddit.clj\n+++ b/test/support/mock_reddit.clj\n@@ -1,14 +1,56 @@\n (ns test.support.mock-reddit\n- \"In-process HTTP stub for Reddit API fixtures (`test/fixtures/reddit/`).\"\n+ \"In-process HTTP stub for Reddit API fixtures + OAuth login endpoints.\"\n (:require [clojure.java.io :as io]\n [clojure.string :as str])\n (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n- [java.net InetSocketAddress]))\n+ [java.net InetSocketAddress URLDecoder]))\n \n (defn fixtures-dir\n ([] (fixtures-dir (System/getProperty \"user.dir\")))\n ([root] (str root \"/test/fixtures/reddit\")))\n \n+(defn- query-param [query key]\n+ (when query\n+ (some (fn [pair]\n+ (let [[k v] (str/split pair \"=\" 2)]\n+ (when (= k key)\n+ (URLDecoder/decode (or v \"\") \"UTF-8\"))))\n+ (str/split query #\"&\"))))\n+\n+(defn- parse-mock-user [raw]\n+ (let [s (or raw \"t2_test:redditor\")\n+ [id login] (str/split s #\":\" 2)]\n+ {:id id :login (or login \"redditor\")}))\n+\n+(defn- send-bytes [^HttpExchange ex status ^bytes body content-type]\n+ (.set (.getResponseHeaders ex) \"Content-Type\" content-type)\n+ (.sendResponseHeaders ex status (alength body))\n+ (doto (.getResponseBody ex)\n+ (.write body)\n+ (.close)))\n+\n+(defn- send-json [^HttpExchange ex status body]\n+ (send-bytes ex status (.getBytes body \"UTF-8\") \"application/json\"))\n+\n+(defn- send-redirect [^HttpExchange ex location]\n+ (.set (.getResponseHeaders ex) \"Location\" location)\n+ (.sendResponseHeaders ex 302 -1)\n+ (.close (.getResponseBody ex)))\n+\n+(defn- read-form [^HttpExchange ex]\n+ (let [body (slurp (.getInputStream ex))]\n+ {:code (query-param body \"code\")\n+ :grant (query-param body \"grant_type\")}))\n+\n+(defn- bearer-token [^HttpExchange ex]\n+ (some-> (.getRequestHeaders ex)\n+ (.getFirst \"Authorization\")\n+ (str/replace #\"^[Bb]earer \" \"\")))\n+\n+(defn- parse-token-user [token]\n+ (when (str/starts-with? token \"mock:\")\n+ (parse-mock-user (subs token 5))))\n+\n (defn start-mock-reddit\n \"Start a mock Reddit API on `port`. Returns a zero-arg `stop` function.\"\n ([port] (start-mock-reddit port (fixtures-dir)))\n@@ -19,15 +61,38 @@\n handler\n (proxy [HttpHandler] []\n (handle [^HttpExchange exchange]\n- ;; `/r//about.json` → subreddit entity; `/r/.json` → listing.\n- (let [path (.getPath (.getRequestURI exchange))\n- body (if (str/includes? path \"/about\")\n- about\n- listing)]\n- (.sendResponseHeaders exchange 200 (alength body))\n- (let [out (.getResponseBody exchange)]\n- (.write out body)\n- (.close out)))))]\n+ (let [uri (.getRequestURI exchange)\n+ path (.getPath uri)\n+ query (.getQuery uri)\n+ method (.getRequestMethod exchange)]\n+ (cond\n+ (str/ends-with? path \"/api/v1/authorize\")\n+ (let [redirect-uri (query-param query \"redirect_uri\")\n+ state (query-param query \"state\")\n+ user (parse-mock-user (query-param query \"mock_user\"))\n+ code (str \"mock:\" (:id user) \":\" (:login user))\n+ loc (str redirect-uri \"?code=\" (java.net.URLEncoder/encode code \"UTF-8\")\n+ \"&state=\" (java.net.URLEncoder/encode state \"UTF-8\"))]\n+ (send-redirect exchange loc))\n+\n+ (and (= method \"POST\") (str/ends-with? path \"/api/v1/access_token\"))\n+ (let [form (read-form exchange)\n+ grant (or (:grant form) \"\")\n+ code (or (:code form) \"mock:t2_test:redditor\")]\n+ (if (= grant \"client_credentials\")\n+ (send-json exchange 200 \"{\\\"access_token\\\":\\\"app-token\\\",\\\"token_type\\\":\\\"bearer\\\",\\\"expires_in\\\":3600}\")\n+ (send-json exchange 200 (str \"{\\\"access_token\\\":\\\"\" code \"\\\",\\\"token_type\\\":\\\"bearer\\\",\\\"expires_in\\\":3600}\"))))\n+\n+ (str/ends-with? path \"/api/v1/me\")\n+ (let [user (or (parse-token-user (bearer-token exchange))\n+ {:id \"t2_test\" :login \"redditor\"})]\n+ (send-json exchange 200\n+ (str \"{\\\"id\\\":\\\"\" (:id user) \"\\\",\\\"name\\\":\\\"\" (:login user) \"\\\"}\")))\n+\n+ ;; `/r//about.json` → subreddit entity; `/r/.json` → listing.\n+ :else\n+ (let [body (if (str/includes? path \"/about\") about listing)]\n+ (send-bytes exchange 200 body \"application/json\"))))))]\n (.createContext server \"/\" handler)\n (.setExecutor server nil)\n (.start server)\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[285b64d4] Offload Reddit payloads to RocksDB and stream event log replay.\n\nVendor durable as a workspace crate, store entity JSON in entity_db instead\nof GlobalTree, and replay events.jsonl one line at a time to cut startup RAM.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex e55d87f32ab32064686431c7082ef8c9ca872d63..8fc09f9ac978bd7ccf57f177989057b606677db8 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,18 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000\"\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -56,6 +68,12 @@ version = \"1.1.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\"\n \n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\"\n+\n [[package]]\n name = \"axum\"\n version = \"0.7.9\"\n@@ -153,6 +171,75 @@ version = \"0.22.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\"\n \n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.72.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.2\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n [[package]]\n name = \"bitflags\"\n version = \"2.11.1\"\n@@ -171,6 +258,22 @@ version = \"1.11.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33\"\n \n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n [[package]]\n name = \"cc\"\n version = \"1.2.62\"\n@@ -178,15 +281,89 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\"\n dependencies = [\n \"find-msvc-tools\",\n+ \"jobserver\",\n+ \"libc\",\n \"shlex\",\n ]\n \n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n [[package]]\n name = \"cfg-if\"\n version = \"1.0.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"\n \n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9\"\n+\n [[package]]\n name = \"cookie\"\n version = \"0.18.1\"\n@@ -224,6 +401,73 @@ version = \"0.8.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b\"\n \n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -250,6 +494,25 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.16.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -373,6 +636,18 @@ dependencies = [\n \"wasi\",\n ]\n \n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi 5.3.0\",\n+ \"wasip2\",\n+]\n+\n [[package]]\n name = \"getrandom\"\n version = \"0.4.2\"\n@@ -381,11 +656,17 @@ checksum = \"0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555\"\n dependencies = [\n \"cfg-if\",\n \"libc\",\n- \"r-efi\",\n+ \"r-efi 6.0.0\",\n \"wasip2\",\n \"wasip3\",\n ]\n \n+[[package]]\n+name = \"glob\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280\"\n+\n [[package]]\n name = \"h2\"\n version = \"0.4.14\"\n@@ -405,6 +686,17 @@ dependencies = [\n \"tracing\",\n ]\n \n+[[package]]\n+name = \"half\"\n+version = \"2.7.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"hashbrown\"\n version = \"0.15.5\"\n@@ -426,6 +718,12 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -676,12 +974,51 @@ version = \"2.12.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2\"\n \n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n [[package]]\n name = \"itoa\"\n version = \"1.0.18\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\"\n \n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.34\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"js-sys\"\n version = \"0.3.99\"\n@@ -700,6 +1037,12 @@ version = \"1.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n \n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n [[package]]\n name = \"leb128fmt\"\n version = \"0.1.0\"\n@@ -712,6 +1055,43 @@ version = \"0.2.186\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\"\n \n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.28\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n [[package]]\n name = \"linux-raw-sys\"\n version = \"0.12.1\"\n@@ -730,6 +1110,16 @@ version = \"0.4.30\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5\"\n \n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -781,6 +1171,12 @@ version = \"0.3.17\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\"\n \n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n [[package]]\n name = \"mio\"\n version = \"1.2.0\"\n@@ -826,6 +1222,16 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n [[package]]\n name = \"nu-ansi-term\"\n version = \"0.50.3\"\n@@ -841,19 +1247,34 @@ version = \"0.2.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\"\n \n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n [[package]]\n name = \"once_cell\"\n version = \"1.21.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50\"\n \n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n [[package]]\n name = \"openssl\"\n version = \"0.10.80\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"cfg-if\",\n \"foreign-types\",\n \"libc\",\n@@ -890,6 +1311,12 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n [[package]]\n name = \"percent-encoding\"\n version = \"2.3.2\"\n@@ -908,6 +1335,34 @@ version = \"0.3.33\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\"\n \n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n [[package]]\n name = \"potential_utf\"\n version = \"0.1.5\"\n@@ -974,6 +1429,31 @@ dependencies = [\n \"unicode-ident\",\n ]\n \n+[[package]]\n+name = \"proptest\"\n+version = \"1.11.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.11.1\",\n+ \"num-traits\",\n+ \"rand 0.9.4\",\n+ \"rand_chacha 0.9.0\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n [[package]]\n name = \"quote\"\n version = \"1.0.45\"\n@@ -983,6 +1463,12 @@ dependencies = [\n \"proc-macro2\",\n ]\n \n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n [[package]]\n name = \"r-efi\"\n version = \"6.0.0\"\n@@ -996,8 +1482,18 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n dependencies = [\n \"libc\",\n- \"rand_chacha\",\n- \"rand_core\",\n+ \"rand_chacha 0.3.1\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea\"\n+dependencies = [\n+ \"rand_chacha 0.9.0\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1007,7 +1503,17 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n dependencies = [\n \"ppv-lite86\",\n- \"rand_core\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1019,6 +1525,56 @@ dependencies = [\n \"getrandom 0.2.17\",\n ]\n \n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core 0.9.5\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.12.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.12.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1090,13 +1646,35 @@ dependencies = [\n \"windows-sys 0.52.0\",\n ]\n \n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\"\n+\n [[package]]\n name = \"rustix\"\n version = \"1.1.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"errno\",\n \"libc\",\n \"linux-raw-sys\",\n@@ -1142,12 +1720,33 @@ version = \"1.0.22\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d\"\n \n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n [[package]]\n name = \"ryu\"\n version = \"1.0.23\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f\"\n \n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"schannel\"\n version = \"0.1.29\"\n@@ -1163,7 +1762,7 @@ version = \"3.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.10.1\",\n \"core-foundation-sys\",\n \"libc\",\n@@ -1307,9 +1906,10 @@ dependencies = [\n \"axum\",\n \"axum-extra\",\n \"dotenvy\",\n+ \"durable\",\n \"futures-util\",\n \"maud\",\n- \"rand\",\n+ \"rand 0.8.6\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1378,7 +1978,7 @@ version = \"0.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.9.4\",\n \"system-configuration-sys\",\n ]\n@@ -1476,6 +2076,16 @@ dependencies = [\n \"zerovec\",\n ]\n \n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n [[package]]\n name = \"tokio\"\n version = \"1.52.3\"\n@@ -1558,7 +2168,7 @@ version = \"0.5.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"http\",\n \"http-body\",\n@@ -1575,7 +2185,7 @@ version = \"0.6.11\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"futures-util\",\n \"http\",\n@@ -1667,6 +2277,12 @@ version = \"0.2.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b\"\n \n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n [[package]]\n name = \"unicode-ident\"\n version = \"1.0.24\"\n@@ -1727,6 +2343,25 @@ version = \"0.9.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a\"\n \n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"want\"\n version = \"0.3.1\"\n@@ -1843,7 +2478,7 @@ version = \"0.244.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"hashbrown 0.15.5\",\n \"indexmap\",\n \"semver\",\n@@ -1859,6 +2494,15 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22\"\n+dependencies = [\n+ \"windows-sys 0.61.2\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\n@@ -2040,7 +2684,7 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2\"\n dependencies = [\n \"anyhow\",\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"indexmap\",\n \"log\",\n \"serde\",\n@@ -2184,3 +2828,14 @@ name = \"zmij\"\n version = \"1.0.21\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\"\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.16+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748\"\n+dependencies = [\n+ \"bindgen 0.72.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/Cargo.toml b/Cargo.toml\nindex 156820ec5151b709a254dafc177da10488fee566..89f57d8943de978ef6c2dc038a5dfb89420c4283 100644\n--- a/Cargo.toml\n+++ b/Cargo.toml\n@@ -1,3 +1,3 @@\n [workspace]\n-members = [\"server\"]\n+members = [\"server\", \"durable\"]\n resolver = \"2\"\ndiff --git a/Dockerfile b/Dockerfile\nindex 38e4d595f2366b6a6e9405f1e12373b70fac79a1..517aa49ea2c01ba0d06a4647259ed917a0f6665c 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -3,7 +3,7 @@ FROM rust:1.88-slim AS builder\n WORKDIR /build\n \n RUN apt-get update && \\\n- apt-get install -y pkg-config libssl-dev && \\\n+ apt-get install -y pkg-config libssl-dev clang && \\\n rm -rf /var/lib/apt/lists/*\n \n COPY . .\ndiff --git a/durable/.gitignore b/durable/.gitignore\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..90c273ef12d6313594dde541c3465f2a47739729\n--- /dev/null\n+++ b/durable/.gitignore\n@@ -0,0 +1,35 @@\n+all.txt\n+\n+# Generated by Cargo\n+# will have compiled files and executables\n+debug/\n+target/\n+\n+# These are backup files generated by rustfmt\n+**/*.rs.bk\n+\n+# MSVC Windows builds of rustc generate these, which store debugging information\n+*.pdb\n+\n+# Generated by cargo mutants\n+# Contains mutation testing data\n+**/mutants.out*/\n+\n+# RustRover\n+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n+# and can be added to the global gitignore or merged into this file. For a more nuclear\n+# option (not recommended) you can uncomment the following to ignore the entire idea folder.\n+#.idea/\n+\n+\n+# Added by cargo\n+\n+/target\n+\n+\n+# Added by cargo\n+#\n+# already existing elements were commented out\n+\n+#/target\ndiff --git a/durable/Cargo.lock b/durable/Cargo.lock\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..99d59ec2166a1e88dc4867d316b3d5fe9bb0a1b6\n--- /dev/null\n+++ b/durable/Cargo.lock\n@@ -0,0 +1,1198 @@\n+# This file is automatically @generated by Cargo.\n+# It is not intended for manual editing.\n+version = 4\n+\n+[[package]]\n+name = \"aho-corasick\"\n+version = \"1.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916\"\n+dependencies = [\n+ \"memchr\",\n+]\n+\n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd\"\n+\n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8\"\n+\n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.71.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.1\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"2.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967\"\n+\n+[[package]]\n+name = \"bumpalo\"\n+version = \"3.18.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee\"\n+\n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n+[[package]]\n+name = \"cc\"\n+version = \"1.2.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc\"\n+dependencies = [\n+ \"jobserver\",\n+ \"libc\",\n+ \"shlex\",\n+]\n+\n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n+[[package]]\n+name = \"cfg-if\"\n+version = \"1.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268\"\n+\n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"0.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675\"\n+\n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929\"\n+\n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.15.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719\"\n+\n+[[package]]\n+name = \"errno\"\n+version = \"0.3.13\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad\"\n+dependencies = [\n+ \"libc\",\n+ \"windows-sys 0.60.2\",\n+]\n+\n+[[package]]\n+name = \"fastrand\"\n+version = \"2.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be\"\n+\n+[[package]]\n+name = \"fnv\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\"\n+\n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi\",\n+ \"wasi\",\n+]\n+\n+[[package]]\n+name = \"glob\"\n+version = \"0.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2\"\n+\n+[[package]]\n+name = \"half\"\n+version = \"2.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+]\n+\n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.16\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itoa\"\n+version = \"1.0.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c\"\n+\n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.33\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a\"\n+dependencies = [\n+ \"getrandom\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"js-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f\"\n+dependencies = [\n+ \"once_cell\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"lazy_static\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n+\n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n+[[package]]\n+name = \"libc\"\n+version = \"0.2.174\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776\"\n+\n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.22\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n+[[package]]\n+name = \"linux-raw-sys\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12\"\n+\n+[[package]]\n+name = \"log\"\n+version = \"0.4.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94\"\n+\n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"memchr\"\n+version = \"2.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0\"\n+\n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n+[[package]]\n+name = \"once_cell\"\n+version = \"1.21.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d\"\n+\n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n+[[package]]\n+name = \"pkg-config\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c\"\n+\n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n+[[package]]\n+name = \"prettyplease\"\n+version = \"0.2.35\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"proc-macro2\"\n+version = \"1.0.95\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"proptest\"\n+version = \"1.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.9.1\",\n+ \"lazy_static\",\n+ \"num-traits\",\n+ \"rand\",\n+ \"rand_chacha\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n+[[package]]\n+name = \"quote\"\n+version = \"1.0.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d\"\n+dependencies = [\n+ \"proc-macro2\",\n+]\n+\n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97\"\n+dependencies = [\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38\"\n+dependencies = [\n+ \"getrandom\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.12.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.11.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-automata\"\n+version = \"0.4.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-syntax\"\n+version = \"0.8.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c\"\n+\n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\"\n+\n+[[package]]\n+name = \"rustix\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"errno\",\n+ \"libc\",\n+ \"linux-raw-sys\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"rustversion\"\n+version = \"1.0.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d\"\n+\n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n+[[package]]\n+name = \"ryu\"\n+version = \"1.0.20\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f\"\n+\n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"serde\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6\"\n+dependencies = [\n+ \"serde_derive\",\n+]\n+\n+[[package]]\n+name = \"serde_derive\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"serde_json\"\n+version = \"1.0.140\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373\"\n+dependencies = [\n+ \"itoa\",\n+ \"memchr\",\n+ \"ryu\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"shlex\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\"\n+\n+[[package]]\n+name = \"syn\"\n+version = \"2.0.104\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"tempfile\"\n+version = \"3.20.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1\"\n+dependencies = [\n+ \"fastrand\",\n+ \"getrandom\",\n+ \"once_cell\",\n+ \"rustix\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"thiserror\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52\"\n+dependencies = [\n+ \"thiserror-impl\",\n+]\n+\n+[[package]]\n+name = \"thiserror-impl\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n+[[package]]\n+name = \"unicode-ident\"\n+version = \"1.0.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512\"\n+\n+[[package]]\n+name = \"vcpkg\"\n+version = \"0.2.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\"\n+\n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"wasi\"\n+version = \"0.14.2+wasi-0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3\"\n+dependencies = [\n+ \"wit-bindgen-rt\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"once_cell\",\n+ \"rustversion\",\n+ \"wasm-bindgen-macro\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-backend\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6\"\n+dependencies = [\n+ \"bumpalo\",\n+ \"log\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407\"\n+dependencies = [\n+ \"quote\",\n+ \"wasm-bindgen-macro-support\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro-support\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-backend\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-shared\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"web-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2\"\n+dependencies = [\n+ \"js-sys\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb\"\n+dependencies = [\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.59.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b\"\n+dependencies = [\n+ \"windows-targets 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.60.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb\"\n+dependencies = [\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.52.6\",\n+ \"windows_aarch64_msvc 0.52.6\",\n+ \"windows_i686_gnu 0.52.6\",\n+ \"windows_i686_gnullvm 0.52.6\",\n+ \"windows_i686_msvc 0.52.6\",\n+ \"windows_x86_64_gnu 0.52.6\",\n+ \"windows_x86_64_gnullvm 0.52.6\",\n+ \"windows_x86_64_msvc 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.53.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.53.0\",\n+ \"windows_aarch64_msvc 0.53.0\",\n+ \"windows_i686_gnu 0.53.0\",\n+ \"windows_i686_gnullvm 0.53.0\",\n+ \"windows_i686_msvc 0.53.0\",\n+ \"windows_x86_64_gnu 0.53.0\",\n+ \"windows_x86_64_gnullvm 0.53.0\",\n+ \"windows_x86_64_msvc 0.53.0\",\n+]\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\"\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486\"\n+\n+[[package]]\n+name = \"wit-bindgen-rt\"\n+version = \"0.39.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+]\n+\n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.15+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237\"\n+dependencies = [\n+ \"bindgen 0.71.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/durable/Cargo.toml b/durable/Cargo.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..907e79cd894446e2ac76c8240b86c2927103727b\n--- /dev/null\n+++ b/durable/Cargo.toml\n@@ -0,0 +1,18 @@\n+[package]\n+name = \"durable\"\n+version = \"0.1.0\"\n+edition = \"2021\"\n+authors = [\"Durable Contributors\"]\n+description = \"RocksDB-backed persistent data structures for Rust\"\n+license = \"MIT OR Apache-2.0\"\n+\n+[dependencies]\n+rocksdb = \"0.21\"\n+serde = { version = \"1.0\", features = [\"derive\"] }\n+bincode = \"1.3\"\n+thiserror = \"1.0\"\n+\n+[dev-dependencies]\n+tempfile = \"3.8\"\n+criterion = \"0.5\"\n+proptest = \"1.4\"\ndiff --git a/durable/LICENSE b/durable/LICENSE\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64\n--- /dev/null\n+++ b/durable/LICENSE\n@@ -0,0 +1,201 @@\n+ Apache License\n+ Version 2.0, January 2004\n+ http://www.apache.org/licenses/\n+\n+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n+\n+ 1. Definitions.\n+\n+ \"License\" shall mean the terms and conditions for use, reproduction,\n+ and distribution as defined by Sections 1 through 9 of this document.\n+\n+ \"Licensor\" shall mean the copyright owner or entity authorized by\n+ the copyright owner that is granting the License.\n+\n+ \"Legal Entity\" shall mean the union of the acting entity and all\n+ other entities that control, are controlled by, or are under common\n+ control with that entity. For the purposes of this definition,\n+ \"control\" means (i) the power, direct or indirect, to cause the\n+ direction or management of such entity, whether by contract or\n+ otherwise, or (ii) ownership of fifty percent (50%) or more of the\n+ outstanding shares, or (iii) beneficial ownership of such entity.\n+\n+ \"You\" (or \"Your\") shall mean an individual or Legal Entity\n+ exercising permissions granted by this License.\n+\n+ \"Source\" form shall mean the preferred form for making modifications,\n+ including but not limited to software source code, documentation\n+ source, and configuration files.\n+\n+ \"Object\" form shall mean any form resulting from mechanical\n+ transformation or translation of a Source form, including but\n+ not limited to compiled object code, generated documentation,\n+ and conversions to other media types.\n+\n+ \"Work\" shall mean the work of authorship, whether in Source or\n+ Object form, made available under the License, as indicated by a\n+ copyright notice that is included in or attached to the work\n+ (an example is provided in the Appendix below).\n+\n+ \"Derivative Works\" shall mean any work, whether in Source or Object\n+ form, that is based on (or derived from) the Work and for which the\n+ editorial revisions, annotations, elaborations, or other modifications\n+ represent, as a whole, an original work of authorship. For the purposes\n+ of this License, Derivative Works shall not include works that remain\n+ separable from, or merely link (or bind by name) to the interfaces of,\n+ the Work and Derivative Works thereof.\n+\n+ \"Contribution\" shall mean any work of authorship, including\n+ the original version of the Work and any modifications or additions\n+ to that Work or Derivative Works thereof, that is intentionally\n+ submitted to Licensor for inclusion in the Work by the copyright owner\n+ or by an individual or Legal Entity authorized to submit on behalf of\n+ the copyright owner. For the purposes of this definition, \"submitted\"\n+ means any form of electronic, verbal, or written communication sent\n+ to the Licensor or its representatives, including but not limited to\n+ communication on electronic mailing lists, source code control systems,\n+ and issue tracking systems that are managed by, or on behalf of, the\n+ Licensor for the purpose of discussing and improving the Work, but\n+ excluding communication that is conspicuously marked or otherwise\n+ designated in writing by the copyright owner as \"Not a Contribution.\"\n+\n+ \"Contributor\" shall mean Licensor and any individual or Legal Entity\n+ on behalf of whom a Contribution has been received by Licensor and\n+ subsequently incorporated within the Work.\n+\n+ 2. Grant of Copyright License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ copyright license to reproduce, prepare Derivative Works of,\n+ publicly display, publicly perform, sublicense, and distribute the\n+ Work and such Derivative Works in Source or Object form.\n+\n+ 3. Grant of Patent License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ (except as stated in this section) patent license to make, have made,\n+ use, offer to sell, sell, import, and otherwise transfer the Work,\n+ where such license applies only to those patent claims licensable\n+ by such Contributor that are necessarily infringed by their\n+ Contribution(s) alone or by combination of their Contribution(s)\n+ with the Work to which such Contribution(s) was submitted. If You\n+ institute patent litigation against any entity (including a\n+ cross-claim or counterclaim in a lawsuit) alleging that the Work\n+ or a Contribution incorporated within the Work constitutes direct\n+ or contributory patent infringement, then any patent licenses\n+ granted to You under this License for that Work shall terminate\n+ as of the date such litigation is filed.\n+\n+ 4. Redistribution. You may reproduce and distribute copies of the\n+ Work or Derivative Works thereof in any medium, with or without\n+ modifications, and in Source or Object form, provided that You\n+ meet the following conditions:\n+\n+ (a) You must give any other recipients of the Work or\n+ Derivative Works a copy of this License; and\n+\n+ (b) You must cause any modified files to carry prominent notices\n+ stating that You changed the files; and\n+\n+ (c) You must retain, in the Source form of any Derivative Works\n+ that You distribute, all copyright, patent, trademark, and\n+ attribution notices from the Source form of the Work,\n+ excluding those notices that do not pertain to any part of\n+ the Derivative Works; and\n+\n+ (d) If the Work includes a \"NOTICE\" text file as part of its\n+ distribution, then any Derivative Works that You distribute must\n+ include a readable copy of the attribution notices contained\n+ within such NOTICE file, excluding those notices that do not\n+ pertain to any part of the Derivative Works, in at least one\n+ of the following places: within a NOTICE text file distributed\n+ as part of the Derivative Works; within the Source form or\n+ documentation, if provided along with the Derivative Works; or,\n+ within a display generated by the Derivative Works, if and\n+ wherever such third-party notices normally appear. The contents\n+ of the NOTICE file are for informational purposes only and\n+ do not modify the License. You may add Your own attribution\n+ notices within Derivative Works that You distribute, alongside\n+ or as an addendum to the NOTICE text from the Work, provided\n+ that such additional attribution notices cannot be construed\n+ as modifying the License.\n+\n+ You may add Your own copyright statement to Your modifications and\n+ may provide additional or different license terms and conditions\n+ for use, reproduction, or distribution of Your modifications, or\n+ for any such Derivative Works as a whole, provided Your use,\n+ reproduction, and distribution of the Work otherwise complies with\n+ the conditions stated in this License.\n+\n+ 5. Submission of Contributions. Unless You explicitly state otherwise,\n+ any Contribution intentionally submitted for inclusion in the Work\n+ by You to the Licensor shall be under the terms and conditions of\n+ this License, without any additional terms or conditions.\n+ Notwithstanding the above, nothing herein shall supersede or modify\n+ the terms of any separate license agreement you may have executed\n+ with Licensor regarding such Contributions.\n+\n+ 6. Trademarks. This License does not grant permission to use the trade\n+ names, trademarks, service marks, or product names of the Licensor,\n+ except as required for reasonable and customary use in describing the\n+ origin of the Work and reproducing the content of the NOTICE file.\n+\n+ 7. Disclaimer of Warranty. Unless required by applicable law or\n+ agreed to in writing, Licensor provides the Work (and each\n+ Contributor provides its Contributions) on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n+ implied, including, without limitation, any warranties or conditions\n+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n+ PARTICULAR PURPOSE. You are solely responsible for determining the\n+ appropriateness of using or redistributing the Work and assume any\n+ risks associated with Your exercise of permissions under this License.\n+\n+ 8. Limitation of Liability. In no event and under no legal theory,\n+ whether in tort (including negligence), contract, or otherwise,\n+ unless required by applicable law (such as deliberate and grossly\n+ negligent acts) or agreed to in writing, shall any Contributor be\n+ liable to You for damages, including any direct, indirect, special,\n+ incidental, or consequential damages of any character arising as a\n+ result of this License or out of the use or inability to use the\n+ Work (including but not limited to damages for loss of goodwill,\n+ work stoppage, computer failure or malfunction, or any and all\n+ other commercial damages or losses), even if such Contributor\n+ has been advised of the possibility of such damages.\n+\n+ 9. Accepting Warranty or Additional Liability. While redistributing\n+ the Work or Derivative Works thereof, You may choose to offer,\n+ and charge a fee for, acceptance of support, warranty, indemnity,\n+ or other liability obligations and/or rights consistent with this\n+ License. However, in accepting such obligations, You may act only\n+ on Your own behalf and on Your sole responsibility, not on behalf\n+ of any other Contributor, and only if You agree to indemnify,\n+ defend, and hold each Contributor harmless for any liability\n+ incurred by, or claims asserted against, such Contributor by reason\n+ of your accepting any such warranty or additional liability.\n+\n+ END OF TERMS AND CONDITIONS\n+\n+ APPENDIX: How to apply the Apache License to your work.\n+\n+ To apply the Apache License to your work, attach the following\n+ boilerplate notice, with the fields enclosed by brackets \"[]\"\n+ replaced with your own identifying information. (Don't include\n+ the brackets!) The text should be enclosed in the appropriate\n+ comment syntax for the file format. We also recommend that a\n+ file or class name and description of purpose be included on the\n+ same \"printed page\" as the copyright notice for easier\n+ identification within third-party archives.\n+\n+ Copyright [yyyy] [name of copyright owner]\n+\n+ Licensed under the Apache License, Version 2.0 (the \"License\");\n+ you may not use this file except in compliance with the License.\n+ You may obtain a copy of the License at\n+\n+ http://www.apache.org/licenses/LICENSE-2.0\n+\n+ Unless required by applicable law or agreed to in writing, software\n+ distributed under the License is distributed on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n+ See the License for the specific language governing permissions and\n+ limitations under the License.\ndiff --git a/durable/README.md b/durable/README.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..46f3b163a41d1094334ea5dc4e8add7b627f16e2\n--- /dev/null\n+++ b/durable/README.md\n@@ -0,0 +1,207 @@\n+# Durable\n+\n+RocksDB-backed persistent data structures for Rust. Think `std::collections` but on disk!\n+\n+## Features\n+\n+- **Persistent Collections**: `DurableVec`, `DurableMap`, `DurableSet` (coming soon)\n+- **Type-Safe**: Full Rust type safety with serde serialization\n+- **ACID Guarantees**: All operations are atomic and crash-safe\n+- **Zero-Copy Capable**: Efficient iteration without loading entire collections\n+- **Embedded**: No external services required - just a directory on disk\n+\n+## Quick Start\n+\n+Add to your `Cargo.toml`:\n+\n+```toml\n+[dependencies]\n+durable = \"0.1.0\"\n+```\n+\n+## Example\n+\n+### DurableVec\n+```rust\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent vector\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Use it like a normal Vec!\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build something amazing\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ // Data persists across program restarts\n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+### DurableMap\n+```rust\n+use durable::{Db, DurableMap};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent map\n+ let mut scores = DurableMap::::new(&db, \"scores\")?;\n+ \n+ // Use it like a HashMap!\n+ // Use put() when you don't need the old value (more efficient)\n+ scores.put(\"Alice\".to_string(), 100)?;\n+ scores.put(\"Bob\".to_string(), 85)?;\n+ \n+ // Use insert() when you need to know the old value\n+ if let Some(old_score) = scores.insert(\"Alice\".to_string(), 120)? {\n+ println!(\"Alice's previous score was: {}\", old_score);\n+ }\n+ \n+ // Get values\n+ if let Some(score) = scores.get(&\"Alice\".to_string())? {\n+ println!(\"Alice's score: {}\", score);\n+ }\n+ \n+ // Iterate over entries\n+ for (name, score) in scores.iter()? {\n+ println!(\"{}: {}\", name, score);\n+ }\n+ \n+ Ok(())\n+}\n+```\n+\n+### Nested Collections\n+\n+Durable supports nesting collections within each other for complex data structures:\n+\n+```rust\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = \n+ DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for a user\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ \n+ // Or use chained calls for convenience\n+ user_posts.entry(\"bob\".to_string())?.or_default()?.push(\"First post!\".to_string())?;\n+ \n+ // Access nested data\n+ let alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ println!(\"Alice has {} posts\", alice_posts.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+The entry API automatically creates nested collections when they don't exist, providing ergonomic access patterns similar to `std::collections::HashMap::entry().or_default()`.\n+\n+## Current Status\n+\n+### Implemented\n+\n+- ✅ `DurableVec` with full test coverage including:\n+ - Basic operations: `push`, `pop`, `get`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()` returns a streaming iterator, `to_vec()` loads into memory\n+ - Property-based testing with proptest\n+ - Unicode string support\n+ - Complex type support\n+\n+- ✅ `DurableMap` with full test coverage including:\n+ - Basic operations: `insert`, `put`, `get`, `remove`, `contains_key`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()`, `keys()`, `values()` return streaming iterators\n+ - Memory loading: `to_vec()`, `keys_vec()`, `values_vec()` for convenience\n+ - Complex key and value types\n+ - Property-based testing with proptest\n+\n+- ✅ **Nested Collections** with entry API:\n+ - `DurableMap>` - Maps to vectors\n+ - `entry()` method with `or_default()` for ergonomic access\n+ - Automatic collection creation and management\n+ - Full persistence and isolation between nested collections\n+ - Type-safe compile-time enforcement\n+\n+### Coming Soon\n+\n+- 🚧 `DurableSet` - Persistent HashSet \n+- 🚧 Deep nesting (e.g., `DurableMap>>`)\n+- 🚧 Schema migration support\n+- 🚧 Batch operations across multiple collections\n+\n+## Performance\n+\n+All operations are designed to be efficient:\n+\n+- **DurableVec**:\n+ - `push`: Single atomic write with WAL flush\n+ - `get`: Direct key lookup, O(1) \n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+ - `clear`: Atomic batch deletion\n+\n+- **DurableMap**:\n+ - `insert`: Returns old value (2 ops: get + put), O(1) average\n+ - `put`: No return value (1 op: existence check + put), O(1) average\n+ - `get`: Direct key lookup, O(1) average\n+ - `remove`: Single delete with WAL flush\n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+\n+## Testing\n+\n+Run the test suite:\n+\n+```bash\n+cargo test\n+```\n+\n+Run the examples:\n+\n+```bash\n+cargo run --example vec_example\n+cargo run --example map_example\n+cargo run --example combined_example # Shows both collections working together\n+cargo run --example streaming_demo # Demonstrates efficient streaming iteration\n+cargo run --example nested_example # Shows nested collections (Map -> Vec)\n+cargo run --example simple_ranking # Gaming leaderboard from docs/motivation.md\n+cargo run --example ranking_history # Complex ranking system with persistence\n+```\n+\n+## License\n+\n+Licensed under either of:\n+\n+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)\n+- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)\n+\n+at your option.\n+\n+## Contributing\n+\n+Contributions are welcome! Please feel free to submit a Pull Request.\ndiff --git a/durable/docs/001.md b/durable/docs/001.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3ad7cf9d93572de9ded367f9ce6dcc28608bba39\n--- /dev/null\n+++ b/durable/docs/001.md\n@@ -0,0 +1,272 @@\n+## Durable — RocksDB-backed Persistent Data Structures for Rust\n+\n+### *draft RFC v0.1*\n+\n+---\n+\n+### 1  Purpose\n+\n+Provide ergonomic, std-like collections (`DurableMap`, `DurableVec`, `DurableSet`, …) whose contents are **durably stored in RocksDB** yet feel in-memory:\n+\n+```rust\n+let db = durable::open(\"db\"); // single call opens RocksDB\n+let mut posts = DurableMap::::new(&db, \"posts\")?;\n+\n+posts.insert(id, post)?; // ACID write\n+let p = posts.get(&id)?; // typed read\n+```\n+\n+Target use cases:\n+\n+| Domain | Why durable? |\n+| ------------------------------ | -------------------------------- |\n+| Local-first apps / CRDT caches | crash-safe, embedded |\n+| High-write time-series blobs | append-only keys, fast iteration |\n+| ML / ranking histories | vector-within-vector patterns |\n+| Game state / async board games | snapshot + rollback |\n+| Sorter (tag → bucket → item) | exactly the motivating structure |\n+\n+---\n+\n+### 2  Design Goals\n+\n+1. **Ergonomic** – Feel like `std::collections`; no manual key mangling.\n+2. **Typed** – Keys & values are generic; (de)serialization pluggable (default = `bincode`).\n+3. **Atomic** – Multi-op `batch.commit()` gives RocksDB write-batch semantics.\n+4. **Crash-safe** – Every public write path `fsync`s RocksDB WAL first.\n+5. **Composable** – Any collection may nest another via *prefix subspaces*.\n+6. **Zero external services** – Single embedded `.sst` directory.\n+7. **Opt-in features** – Reactive diffs, LRU cache, metrics behind feature flags.\n+\n+Non-goals (v0 line):\n+\n+* Distributed replication\n+* Multi-process concurrency (one writer process is assumed; readers may open secondary Rocks instances)\n+* SQL-style ad-hoc queries – you iterate, not query-plan.\n+\n+---\n+\n+### 3  Key–Value Layout\n+\n+*Each collection owns a **prefix** inside a single Column Family (`default` by default).*\n+\n+```\n+ 0x00 0x00 [ 0x00 …]\n+```\n+\n+* `user-prefix` = crate-level namespace (allows multi-tenant apps)\n+* `col_id` = 8-byte, little-endian numeric ID assigned on `DurableMap::new(&db,\"posts\")`.\n+* `logical-key` = `serde`-encoded key (or ordinal for Vec).\n+* `subindex` = extra path segments used by nested collections (e.g., `Vec` elements in a map value).\n+\n+Because RocksDB stores keys lexicographically, all elements of a collection (and its descendants) live contiguously → range scans & prefix deletes are cheap.\n+\n+---\n+\n+### 4  Public API (surface)\n+\n+```rust\n+/// Opens (or creates) a durable database at `path`.\n+pub fn open>(path: P) -> Result;\n+\n+/// A transactional write batch\n+pub struct Batch<'db> { /* .. */ }\n+\n+impl<'db> Batch<'db> {\n+ pub fn put(&mut self, col: &impl WriteCollection, key: &K, val: &V) -> Result<()>;\n+ pub fn delete(&mut self, col: &impl WriteCollection, key: &K) -> Result<()>;\n+ pub fn commit(self) -> Result<()>;\n+}\n+\n+/// Collections ------------------------------------------------------------\n+\n+pub struct DurableMap<'db, K, V> { /* .. */ }\n+pub struct DurableVec<'db, T> { /* .. */ }\n+pub struct DurableSet<'db, T> { /* .. */ }\n+pub struct DurableIndex<'db, K, V> { /* sorted-map, range queries */ }\n+\n+/// Common read API\n+pub trait ReadCollection {\n+ fn get(&self, k: &K) -> Result>;\n+ fn contains_key(&self, k: &K) -> Result;\n+ fn len(&self) -> Result;\n+ fn iter(&self) -> Iter<'_, (K,V)>; // owns snapshot\n+}\n+\n+/// Common write API (auto batch or explicit)\n+pub trait WriteCollection: ReadCollection {\n+ fn insert(&mut self, k: K, v: V) -> Result>;\n+ fn remove(&mut self, k: &K) -> Result>;\n+ fn clear(&mut self) -> Result<()>;\n+}\n+```\n+\n+*All writes go through an internal `rocksdb::WriteBatch`* so a single logical op remains atomic even if it touches subkeys (e.g., pushing into a `DurableVec` updates `len` meta key + appends element).\n+\n+---\n+\n+### 5  Collection Semantics\n+\n+#### 5.1 `DurableVec`\n+\n+* Meta-key `__len` stores current length (`u64`).\n+* Element key = `|`\n+* `push(elem)` = `batch.put(key(len), elem); batch.put(__len, len+1)`\n+ – O(1) write, O(log N) read via prefix seek.\n+* `iter()` performs a prefix range; snapshot guarantees repeat-read.\n+\n+#### 5.2 `DurableMap`\n+\n+* Key = `|`\n+* `len` optional (feature `\"size_tracking\"`); otherwise O(prefix-scan).\n+\n+#### 5.3 Nested Collections\n+\n+```rust\n+let users = DurableMap::>::new(&db,\"users\")?;\n+users.entry(\"alice\")?.or_default()?.push(order)?;\n+```\n+\n+Internally `Entry::or_default` creates a *child prefix* off the parent’s key:\n+`|\"alice\"|0x00||…`\n+\n+Child collections store their own metadata keys beneath that path.\n+\n+---\n+\n+### 6  Transactions & Consistency\n+\n+* **Auto-batch**: default mutator methods create a WriteBatch, commit, and flush WAL.\n+* **Explicit batch**: user opens `let mut wb = db.batch();`, issues puts/deletes via collection adapters, then `wb.commit()` for cross-collection atomicity.\n+* **Crash guarantee**: after `commit` returns, updates survive power loss (`rocksdb::DB::flush_wal(true)`).\n+\n+Read operations take a **consistent snapshot** by default; advanced users can opt-out for max throughput.\n+\n+---\n+\n+### 7  Migrations (v0.2 roadmap)\n+\n+* Each collection stores a `u32 schema_version` meta key.\n+* `durable::open` accepts an optional `Schema` describing:\n+\n+ ```rust\n+ struct Schema { collections: Vec, version: u32 }\n+ ```\n+\n+ If version mismatch ⇒ run user-supplied `migrate(old, new, &db)` which gets a mutable view and may batch-rewrite keys.\n+\n+---\n+\n+### 8  Reactive Diffs (feature `\"watch\"`)\n+\n+* Behind a Tokio-aware feature; uses RocksDB’s `get_updates_since(seq)` API.\n+* `watch_prefix(prefix) -> impl Stream`\n+ – `Diff` = key, old val (Option), new val (Option).\n+* Back-pressure handled with an in-process ring buffer; user chooses lag policy.\n+\n+---\n+\n+### 9  Caching Layer (feature `\"cache\"`)\n+\n+* Probabilistic LRU over deserialized values.\n+* Configurable per-collection: `with_cache(cap_entries, ttl_ms)`.\n+* Coherent: write path invalidates cache keys on commit.\n+\n+---\n+\n+### 10  Error Model\n+\n+```rust\n+#[non_exhaustive]\n+pub enum Error {\n+ Rocks(rocksdb::Error),\n+ Serde(bincode::Error),\n+ Corruption(String),\n+ TransactionAborted,\n+ // feature-gated variants e.g. WatchLagged\n+}\n+```\n+\n+`Result = std::result::Result` everywhere.\n+\n+---\n+\n+### 11  Performance Budget (baseline targets)\n+\n+| Operation | Goal |\n+| -------------------- | -------------------------------------- |\n+| `map.insert` | < 30 µs (including WAL fsync) |\n+| `vec.push` | < 25 µs |\n+| Prefix iteration 1 M | > 120 MB/s read BW on NVMe |\n+| Concurrent readers | Linear scaling up to RocksDB read-IOPS |\n+| Watch latency | < 5 ms p50 on local SSD |\n+\n+(bench harness lives in `benches/` via Criterion.)\n+\n+---\n+\n+### 12  Dependency Footprint\n+\n+* `rocksdb` (–> FB fork) ⟹ builds C++11 static lib (\\~10 MB)\n+* `bincode` (default), with `serde` feature.\n+* `tokio-stream` only if `watch` feature enabled.\n+\n+MSRV = 1.76.\n+\n+---\n+\n+### 13  Minimum Deliverable for **v0.1-alpha**\n+\n+* [ ] `Db::open`, `Db::batch`\n+* [ ] `DurableMap`, `DurableVec`\n+* [ ] automatic serialization via `serde`\n+* [ ] atomic commit + WAL flush\n+* [ ] snapshot reads\n+* [ ] unit tests (insert/get/iter/crash-recovery using `tempdir`)\n+* [ ] criterion bench\n+\n+---\n+\n+### 14  Future Work\n+\n+* **Replicated mode** (Raft or FoundationDB layer)\n+* **CRDT merge semantics** for offline edits\n+* **DurableGraph** (adjacency lists + index)\n+* WebAssembly key-value adapters (edge workers)\n+* `tracing` instrumentation & Prometheus metrics\n+\n+---\n+\n+### 15  Licensing & Governance\n+\n+* License: **Apache-2.0 OR MIT** (standard Rust dual license)\n+* Code-of-conduct: Rust CoC template\n+* Contribution model: PR + mandatory CI (fmt, clippy, test, bench)\n+* Early roadmap guided by original author(s); transfer to an org when ≥3 maintainers.\n+\n+---\n+\n+## Appendix A — Sorter Use-Case Sketch\n+\n+```rust\n+type TagId = String;\n+type Bucket = u64; // logical Unix day or version #\n+type ItemId = String;\n+type Elo = i32;\n+\n+let hist = DurableMap::>>::new(&db,\"hist\")?;\n+\n+// update elo\n+hist.entry(\"tf2\")?\n+ .or_default()?\n+ .entry(today_bucket)?\n+ .or_default()?\n+ .push((item_id, new_elo))?;\n+\n+// stream bucket\n+let items = hist.get(\"tf2\")?.unwrap()\n+ .get(&today_bucket)?.unwrap()\n+ .iter().collect::>();\n+```\n+\n+All three layers share one RocksDB instance; you pay one WAL flush per ranking update, but reads are prefix-scans with zero allocations.\ndiff --git a/durable/docs/motivation.md b/durable/docs/motivation.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..50d8ee5ef9f384d908e3a86f6945e3f4ced58292\n--- /dev/null\n+++ b/durable/docs/motivation.md\n@@ -0,0 +1,248 @@\n+# Why Durable? The Missing Abstraction Layer for Persistent Storage\n+\n+## The Problem: The Abstraction Gap\n+\n+Every database forces developers to translate between how they **think** about data and how they **store** it. This translation layer is where bugs hide, performance suffers, and development slows down.\n+\n+### Example: Building a Multiplayer Game Leaderboard\n+\n+Let's say you need to store player rankings by game mode, with history by day. Here's the data model in your head:\n+\n+```\n+Game Mode → Day → List of (Player, Score)\n+```\n+\n+#### With Raw Key-Value Stores (sled, RocksDB)\n+\n+```rust\n+// Storing a score requires manual key construction\n+let key = format!(\"leaderboard:{}:{}:player:{}\", game_mode, day, player_id);\n+db.insert(key.as_bytes(), score.to_le_bytes())?;\n+\n+// Getting today's leaderboard? Manual prefix scan and deserialization\n+let prefix = format!(\"leaderboard:{}:{}:\", game_mode, day);\n+let mut scores = Vec::new();\n+for item in db.scan_prefix(prefix.as_bytes()) {\n+ let (key, value) = item?;\n+ // Parse player_id from key string... hope the format is right\n+ // Deserialize score... hope it's the right type\n+ scores.push((player_id, score));\n+}\n+scores.sort_by_key(|(_, s)| *s);\n+\n+// Want to know how many players played today? Another scan!\n+// Want to atomically update multiple scores? Write a transaction wrapper!\n+// Want to clean up old days? Manual prefix iteration and deletion!\n+```\n+\n+**Problems:**\n+- String manipulation for every operation\n+- No type safety (everything is bytes)\n+- Manual implementation of collection semantics\n+- No atomicity across related keys\n+- Performance overhead from string parsing\n+\n+#### With SQL Databases (SQLite, PostgreSQL)\n+\n+```sql\n+CREATE TABLE leaderboards (\n+ game_mode VARCHAR(50),\n+ day DATE,\n+ player_id UUID,\n+ score INTEGER,\n+ PRIMARY KEY (game_mode, day, player_id)\n+);\n+\n+-- Getting a leaderboard requires SQL\n+SELECT player_id, score \n+FROM leaderboards \n+WHERE game_mode = ? AND day = ?\n+ORDER BY score DESC;\n+```\n+\n+```rust\n+// In Rust, you need an ORM or manual query building\n+let scores: Vec<(Uuid, i32)> = sqlx::query_as(\n+ \"SELECT player_id, score FROM leaderboards WHERE game_mode = $1 AND day = $2 ORDER BY score DESC\"\n+)\n+.bind(&game_mode)\n+.bind(&day)\n+.fetch_all(&pool)\n+.await?;\n+```\n+\n+**Problems:**\n+- Impedance mismatch (relational model vs nested structures)\n+- SQL complexity for simple operations\n+- ORMs add abstraction layers and performance overhead\n+- Async runtime required even for local storage\n+- Schema migrations for every structural change\n+\n+#### With Document Stores (MongoDB)\n+\n+```javascript\n+// Document structure\n+{\n+ game_mode: \"ranked\",\n+ day: \"2024-01-15\",\n+ scores: [\n+ { player_id: \"abc\", score: 1500 },\n+ { player_id: \"def\", score: 1400 }\n+ ]\n+}\n+\n+// But now you have a different problem: updating a single score\n+// requires loading and saving the entire document!\n+```\n+\n+**Problems:**\n+- Not embedded (requires separate process)\n+- Document size limits\n+- Inefficient for partial updates\n+- Complex setup for local-first apps\n+\n+## The Solution: Native Data Structures\n+\n+With Durable, you express your data model directly:\n+\n+```rust\n+let leaderboard = DurableMap::>>::new(&db, \"leaderboard\")?;\n+\n+// Store a score - reads like natural Rust code\n+leaderboard\n+ .entry(game_mode)?\n+ .or_default()?\n+ .entry(day)?\n+ .or_default()?\n+ .push((player_id, score))?;\n+\n+// Get today's leaderboard - it's just a Vec\n+let mut today_scores = leaderboard\n+ .get(&game_mode)?\n+ .and_then(|mode| mode.get(&day).ok())\n+ .unwrap_or_default();\n+today_scores.sort_by_key(|(_, s)| *s);\n+\n+// All operations are atomic, typed, and efficient\n+```\n+\n+## Why This Matters\n+\n+### 1. **Zero Translation Overhead**\n+\n+Your mental model **is** the storage model. No more:\n+- String concatenation for keys\n+- Manual serialization/deserialization \n+- SQL query construction\n+- Document structure mapping\n+\n+### 2. **Composition Without Complexity**\n+\n+Nested data structures \"just work\":\n+\n+```rust\n+// A real-world example: user notifications by app by priority\n+let notifications = DurableMap::>>>::new(&db, \"notifs\")?;\n+\n+// Natural access patterns\n+notifications\n+ .get(&user_id)?\n+ .get(&app_id)?\n+ .get(&Priority::High)?\n+ .iter()\n+ .take(10) // Latest 10 high-priority notifications\n+```\n+\n+Try implementing this with SQL joins or KV prefixes!\n+\n+### 3. **Type Safety Throughout**\n+\n+```rust\n+// This won't compile - type safety at every level\n+let score: String = leaderboard.get(&\"chess\")?.get(&20240115)?.get(0)?;\n+// ^^^^^^ expected Score, found String\n+\n+// With raw KV stores, this is a runtime error after deserialization\n+```\n+\n+### 4. **Atomicity By Design**\n+\n+```rust\n+// Multiple operations in one atomic batch\n+let mut batch = db.batch();\n+batch.vec_push(&game.players, new_player)?;\n+batch.map_insert(&game.scores, player_id, 0)?;\n+batch.map_increment(&game.stats, \"player_count\", 1)?;\n+batch.commit()?; // All or nothing\n+```\n+\n+### 5. **Performance Without Compromise**\n+\n+- **Locality**: Related data stored contiguously (prefix design)\n+- **Zero-copy possible**: Direct memory mapping for read-heavy workloads\n+- **Streaming iteration**: No need to load entire collections\n+- **Bulk operations**: Native batch support\n+\n+## Comparison Matrix\n+\n+| Feature | Durable | sled/RocksDB | SQLite | MongoDB |\n+|---------|---------|--------------|---------|----------|\n+| **Native collections** | ✅ Built-in | ❌ DIY | ❌ Tables only | ⚠️ Documents |\n+| **Type safety** | ✅ Full | ❌ Bytes | ⚠️ ORM-dependent | ⚠️ Schema validation |\n+| **Nested structures** | ✅ Natural | ❌ Manual prefixes | ❌ Joins/JSON | ✅ Embedded docs |\n+| **Atomic operations** | ✅ Automatic | ⚠️ Manual batching | ✅ Transactions | ⚠️ Document-level |\n+| **Local/embedded** | ✅ Yes | ✅ Yes | ✅ Yes | ❌ Separate process |\n+| **Schema evolution** | ✅ Per-collection | ❌ DIY | ⚠️ Migrations | ✅ Flexible |\n+| **Memory efficiency** | ✅ Scan & stream | ✅ Manual | ⚠️ Query-dependent | ❌ Doc loading |\n+\n+## Real-World Use Cases Where Durable Shines\n+\n+### Local-First Sync Engine\n+\n+```rust\n+// Sync state with conflict tracking\n+let sync_state = DurableMap::>::new(&db, \"sync\")?;\n+\n+// Natural conflict detection\n+let versions = sync_state.get(&record_id)?;\n+if versions.values().unique().count() > 1 {\n+ // Conflict detected - handle naturally\n+}\n+```\n+\n+### Time-Series Analytics Cache\n+\n+```rust\n+// Metrics by source by minute\n+let metrics = DurableMap::>>::new(&db, \"metrics\")?;\n+\n+// Natural windowing\n+let last_hour: Vec = metrics\n+ .get(&source)?\n+ .range(now - 3600..=now)?\n+ .flat_map(|(_, minute_metrics)| minute_metrics.iter())\n+ .collect();\n+```\n+\n+### Feature Flag System with History\n+\n+```rust\n+// Flags by environment with change history\n+let flags = DurableMap::>>::new(&db, \"flags\")?;\n+\n+// Natural audit trail\n+let history = flags.get(&Env::Prod)?.get(\"new-feature\")?.iter().collect();\n+```\n+\n+## The Bottom Line\n+\n+**Durable isn't a better database - it's the missing abstraction layer that lets you use persistent storage like in-memory collections.**\n+\n+Stop translating. Start building.\n+\n+---\n+\n+*Next: Read the [RFC](001.md) for implementation details, or jump to the [Quick Start Guide](quickstart.md).* \n\\ No newline at end of file\ndiff --git a/durable/examples/combined_example.rs b/durable/examples/combined_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..626a6e1cf7e3c9c26f9f2edc58950d9bc31ec67e\n--- /dev/null\n+++ b/durable/examples/combined_example.rs\n@@ -0,0 +1,160 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+use std::time::{SystemTime, UNIX_EPOCH};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct Message {\n+ id: u64,\n+ from: String,\n+ to: String,\n+ content: String,\n+ timestamp: u64,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct User {\n+ username: String,\n+ display_name: String,\n+ message_count: u32,\n+}\n+\n+fn get_timestamp() -> u64 {\n+ SystemTime::now()\n+ .duration_since(UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs()\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"chat_db\")?;\n+ \n+ // Create our collections\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ let mut messages = DurableVec::::new(&db, \"messages\")?;\n+ let mut user_message_indices = DurableMap::>::new(&db, \"user_messages\")?;\n+ \n+ // Create some users\n+ users.insert(\"alice\".to_string(), User {\n+ username: \"alice\".to_string(),\n+ display_name: \"Alice Smith\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"bob\".to_string(), User {\n+ username: \"bob\".to_string(),\n+ display_name: \"Bob Johnson\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"charlie\".to_string(), User {\n+ username: \"charlie\".to_string(),\n+ display_name: \"Charlie Brown\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ // Helper to send a message\n+ let send_message = |from: &str, to: &str, content: &str, \n+ messages: &mut DurableVec,\n+ users: &mut DurableMap,\n+ indices: &mut DurableMap>| -> Result<(), Box> {\n+ // Create message\n+ let msg_id = messages.len()? as u64;\n+ let message = Message {\n+ id: msg_id,\n+ from: from.to_string(),\n+ to: to.to_string(),\n+ content: content.to_string(),\n+ timestamp: get_timestamp(),\n+ };\n+ \n+ // Store message\n+ messages.push(message)?;\n+ let msg_index = messages.len()? - 1;\n+ \n+ // Update sender's message count\n+ if let Some(mut sender) = users.get(&from.to_string())? {\n+ sender.message_count += 1;\n+ users.insert(from.to_string(), sender)?;\n+ }\n+ \n+ // Track message indices for recipient\n+ let mut recipient_indices = indices.get(&to.to_string())?.unwrap_or_default();\n+ recipient_indices.push(msg_index);\n+ indices.insert(to.to_string(), recipient_indices)?;\n+ \n+ Ok(())\n+ };\n+ \n+ // Send some messages\n+ println!(\"💬 Chat Application Demo\\n\");\n+ println!(\"Sending messages...\");\n+ \n+ send_message(\"alice\", \"bob\", \"Hey Bob, how's the Durable library coming along?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"alice\", \"It's going great! We have DurableVec and DurableMap working!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"charlie\", \"alice\", \"That sounds awesome! Can I help with testing?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"alice\", \"charlie\", \"Absolutely! The more testing the better!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"charlie\", \"Check out the examples directory for usage patterns\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ // Display all users and their message counts\n+ println!(\"\\n👥 Users:\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, user) in all_users {\n+ println!(\" {} ({}) - {} messages sent\", \n+ user.display_name, username, user.message_count);\n+ }\n+ \n+ // Display all messages\n+ println!(\"\\n📨 All messages:\");\n+ for (i, msg) in messages.iter()?.enumerate() {\n+ let msg = msg?;\n+ println!(\" [{}] {} → {}: {}\", i, msg.from, msg.to, msg.content);\n+ }\n+ \n+ // Show inbox for each user\n+ println!(\"\\n📥 User inboxes:\");\n+ for item in users.iter() {\n+ let (username, _) = item?;\n+ if let Some(indices) = user_message_indices.get(&username)? {\n+ println!(\"\\n {}'s inbox ({} messages):\", username, indices.len());\n+ for &idx in &indices {\n+ if let Some(msg) = messages.get(idx)? {\n+ println!(\" From {}: {}\", msg.from, msg.content);\n+ }\n+ }\n+ }\n+ }\n+ \n+ // Statistics\n+ println!(\"\\n📊 Statistics:\");\n+ println!(\" Total users: {}\", users.len()?);\n+ println!(\" Total messages: {}\", messages.len()?);\n+ \n+ // Demonstrate persistence\n+ println!(\"\\n💾 Data has been persisted to disk!\");\n+ println!(\" Database location: ./chat_db\");\n+ \n+ // Clean up\n+ drop(messages);\n+ drop(users);\n+ drop(user_message_indices);\n+ drop(db);\n+ \n+ // Remove the database for this example\n+ std::fs::remove_dir_all(\"chat_db\").ok();\n+ \n+ println!(\"\\n✅ Example completed!\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/map_example.rs b/durable/examples/map_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..08b8f2c8826caf53c4c20b422a540c92f6624029\n--- /dev/null\n+++ b/durable/examples/map_example.rs\n@@ -0,0 +1,99 @@\n+use durable::{Db, DurableMap};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct UserProfile {\n+ name: String,\n+ email: String,\n+ score: u32,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent map of user profiles\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ \n+ // Insert some users\n+ // Using put() when we don't need the old value - more efficient!\n+ users.put(\n+ \"alice\".to_string(),\n+ UserProfile {\n+ name: \"Alice Smith\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ score: 1500,\n+ },\n+ )?;\n+ \n+ users.put(\n+ \"bob\".to_string(),\n+ UserProfile {\n+ name: \"Bob Johnson\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ score: 1200,\n+ },\n+ )?;\n+ \n+ // Using insert() when we might need the old value\n+ let old_charlie = users.insert(\n+ \"charlie\".to_string(),\n+ UserProfile {\n+ name: \"Charlie Brown\".to_string(),\n+ email: \"charlie@example.com\".to_string(),\n+ score: 1800,\n+ },\n+ )?;\n+ \n+ if old_charlie.is_some() {\n+ println!(\"Replaced existing charlie entry\");\n+ }\n+ \n+ println!(\"Total users: {}\", users.len()?);\n+ \n+ // Look up a specific user\n+ if let Some(alice) = users.get(&\"alice\".to_string())? {\n+ println!(\"\\nAlice's profile: {:?}\", alice);\n+ }\n+ \n+ // Check if a user exists\n+ println!(\"\\nDoes 'david' exist? {}\", users.contains_key(&\"david\".to_string())?);\n+ \n+ // Update a user's score\n+ if let Some(mut bob) = users.get(&\"bob\".to_string())? {\n+ bob.score += 100;\n+ // Use put() here since we don't need the old value back\n+ users.put(\"bob\".to_string(), bob)?;\n+ println!(\"Updated Bob's score!\");\n+ }\n+ \n+ // Iterate over all users\n+ println!(\"\\nAll users (sorted by username):\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, profile) in all_users {\n+ println!(\" {} ({}) - Score: {}\", username, profile.email, profile.score);\n+ }\n+ \n+ // Get just the usernames\n+ let mut usernames = users.keys_vec()?;\n+ usernames.sort();\n+ println!(\"\\nAll usernames: {:?}\", usernames);\n+ \n+ // Find the highest scoring user\n+ let profiles = users.values_vec()?;\n+ if let Some(top_user) = profiles.iter().max_by_key(|p| p.score) {\n+ println!(\"\\nTop scorer: {} with {} points\", top_user.name, top_user.score);\n+ }\n+ \n+ // Remove a user\n+ if let Some(removed) = users.remove(&\"charlie\".to_string())? {\n+ println!(\"\\nRemoved user: {}\", removed.name);\n+ println!(\"Users remaining: {}\", users.len()?);\n+ }\n+ \n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/nested_example.rs b/durable/examples/nested_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3b880f2c8b8ad2633d4b5fcf2016652216c9de8e\n--- /dev/null\n+++ b/durable/examples/nested_example.rs\n@@ -0,0 +1,64 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ // Open a database\n+ let db = Db::open(\"nested_example_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for Alice\n+ println!(\"Adding posts for Alice...\");\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ alice_posts.push(\"Loving persistent data structures!\".to_string())?;\n+ \n+ // Add posts for Bob\n+ println!(\"Adding posts for Bob...\");\n+ let mut bob_posts = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ bob_posts.push(\"First post\".to_string())?;\n+ bob_posts.push(\"Learning Rust\".to_string())?;\n+ \n+ // Add a post for Charlie in a chained call\n+ println!(\"Adding post for Charlie...\");\n+ user_posts.entry(\"charlie\".to_string())?.or_default()?.push(\"One-liner post!\".to_string())?;\n+ \n+ // Read back Alice's posts\n+ println!(\"\\nAlice's posts:\");\n+ let alice_posts_read = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ for i in 0..alice_posts_read.len()? {\n+ if let Some(post) = alice_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Bob's posts\n+ println!(\"\\nBob's posts:\");\n+ let bob_posts_read = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ for i in 0..bob_posts_read.len()? {\n+ if let Some(post) = bob_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Charlie's posts\n+ println!(\"\\nCharlie's posts:\");\n+ let charlie_posts_read = user_posts.entry(\"charlie\".to_string())?.or_default()?;\n+ for i in 0..charlie_posts_read.len()? {\n+ if let Some(post) = charlie_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ println!(\"\\nDemonstration of persistence...\");\n+ println!(\"Data is now persisted to disk. You can stop and restart this program,\");\n+ println!(\"and all the posts will still be there!\");\n+ \n+ println!(\"\\nTotal users with posts: 3\");\n+ println!(\"Alice has {} posts\", alice_posts_read.len()?);\n+ println!(\"Bob has {} posts\", bob_posts_read.len()?);\n+ println!(\"Charlie has {} posts\", charlie_posts_read.len()?);\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/ranking_history.rs b/durable/examples/ranking_history.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4da8592d240318318ae20c834d616210edb16c8d\n--- /dev/null\n+++ b/durable/examples/ranking_history.rs\n@@ -0,0 +1,179 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]\n+struct RankingEntry {\n+ player_id: String,\n+ score: i32,\n+ timestamp: u64,\n+}\n+\n+impl RankingEntry {\n+ fn new(player_id: &str, score: i32) -> Self {\n+ Self {\n+ player_id: player_id.to_string(),\n+ score,\n+ timestamp: std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs(),\n+ }\n+ }\n+}\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🎮 Gaming Ranking History System (Rewritten with Nested Entry API)\");\n+ println!(\"================================================================\");\n+ \n+ let db = Db::open(\"ranking_history_db\")?;\n+ \n+ // THE CORE CHANGE: Define the truly nested data structure.\n+ // Instead of a composite key, we nest a Map within a Map.\n+ // This represents the ideal, ergonomic API.\n+ type DailyRankings = DurableVec;\n+ type GameHistory = DurableMap;\n+ type Rankings = DurableMap;\n+\n+ let rankings: Rankings = DurableMap::new_nested(&db, \"game_rankings_v2\");\n+ \n+ // Simulate some game days\n+ let today = 20241215u32;\n+ let yesterday = 20241214u32;\n+ let last_week = 20241208u32;\n+ \n+ // No more `make_key` helper function!\n+ \n+ println!(\"\\n📊 Adding ranking data using chained entry().or_default()...\");\n+ \n+ // Add rankings for CS2 today. This demonstrates the new, clean access pattern.\n+ println!(\"Adding CS2 rankings for today ({})\", today);\n+ let mut cs2_today = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()? // Returns GameHistory (DurableMap) for \"cs2\"\n+ .entry(today)?\n+ .or_default()?; // Returns DailyRankings (DurableVec<...>) for `today`\n+\n+ cs2_today.push(RankingEntry::new(\"player1\", 2450))?;\n+ cs2_today.push(RankingEntry::new(\"player2\", 2380))?;\n+ cs2_today.push(RankingEntry::new(\"player3\", 2320))?;\n+ cs2_today.push(RankingEntry::new(\"player4\", 2280))?;\n+ \n+ // Add rankings for CS2 yesterday\n+ println!(\"Adding CS2 rankings for yesterday ({})\", yesterday);\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player1\", 2420))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player2\", 2350))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player5\", 2300))?;\n+\n+ // Add rankings for Valorant today\n+ println!(\"Adding Valorant rankings for today ({})\", today);\n+ let mut valorant_today = rankings\n+ .entry(\"valorant\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?;\n+\n+ valorant_today.push(RankingEntry::new(\"player6\", 1850))?;\n+ valorant_today.push(RankingEntry::new(\"player7\", 1820))?;\n+ valorant_today.push(RankingEntry::new(\"player1\", 1800))?; // Same player, different game\n+ \n+ // Add TF2 rankings (matching the docs example)\n+ println!(\"Adding TF2 rankings for last week ({})\", last_week);\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"veteran_player\", 3200))?;\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"old_school_gamer\", 3150))?;\n+ \n+ println!(\"\\n🏆 Reading back ranking data with the same natural API...\");\n+ \n+ // Get today's CS2 leaderboard\n+ println!(\"\\n🎯 CS2 Leaderboard for {} (today):\", today);\n+ let mut today_rankings = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?\n+ .to_vec()?;\n+\n+ // Sort by score descending\n+ today_rankings.sort_by(|a, b| b.score.cmp(&a.score));\n+ \n+ for (rank, entry) in today_rankings.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, entry.player_id, entry.score);\n+ }\n+ \n+ // Show cross-game analysis is still easy\n+ println!(\"\\n🎮 Multi-game player analysis for player1 on {}:\", today);\n+ let cs2_player1_score = today_rankings.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ let valorant_player1_score = valorant_today.to_vec()?.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ if let Some(score) = cs2_player1_score { println!(\" CS2 Score: {}\", score); }\n+ if let Some(score) = valorant_player1_score { println!(\" Valorant Score: {}\", score); }\n+ \n+ // Showcase the power of the nested structure for stats\n+ // For nested collections, we use the keys API instead of iter()\n+ println!(\"\\n📊 Dynamic Database Statistics (discovered games):\");\n+ \n+ // Note: For nested collections, we iterate over known keys or use a different approach\n+ // since the values (nested DurableMaps) cannot be directly deserialized\n+ let games = vec![\"cs2\", \"valorant\", \"tf2\"]; // In a real app, you might track these separately\n+ \n+ for game in games {\n+ let game_history = rankings.entry(game.to_string())?.or_default()?;\n+ let active_days = game_history.len()?;\n+ \n+ if active_days > 0 {\n+ // For demonstration, let's count entries from known days\n+ let mut total_entries = 0;\n+ let days = [today, yesterday, last_week];\n+ \n+ for day in days {\n+ if let Ok(daily_rankings) = game_history.entry(day) {\n+ if let Ok(rankings_vec) = daily_rankings.or_default() {\n+ total_entries += rankings_vec.len()?;\n+ }\n+ }\n+ }\n+ \n+ if total_entries > 0 {\n+ println!(\" • {}: {} total entries across {} active day(s)\", \n+ game.to_uppercase(), total_entries, active_days);\n+ }\n+ }\n+ }\n+ \n+ println!(\"\\n✨ Key Benefits of This Rewritten Approach:\");\n+ println!(\" • No more manual key construction (`format!`) - the core goal is met!\");\n+ println!(\" • The code's structure now mirrors the mental model: `rankings[game][day]`\");\n+ println!(\" • Truly compositional API, unlocking more powerful dynamic queries (like the stats section)\");\n+ println!(\" • Demonstrates the full power of the `DurableCollection` and `entry()` design.\");\n+\n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/simple_ranking.rs b/durable/examples/simple_ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3cc873c41f3d93c7fa9e6e2dfc80e815f456b1f9\n--- /dev/null\n+++ b/durable/examples/simple_ranking.rs\n@@ -0,0 +1,68 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🏆 Simple Game Ranking Example\");\n+ println!(\"Demonstrating the pattern from docs/motivation.md\");\n+ println!(\"===============================================\");\n+ \n+ let db = Db::open(\"simple_ranking_db\")?;\n+ \n+ // This is the exact pattern from the docs: Game Mode → List of (Player, Score)\n+ // For simplicity, we're showing one day's data per game mode\n+ let rankings: DurableMap> = DurableMap::new_nested(&db, \"rankings\");\n+ \n+ println!(\"\\n📊 Adding TF2 rankings (from the docs example)...\");\n+ \n+ // This is the exact code pattern shown in docs/motivation.md\n+ let mut tf2_rankings = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ tf2_rankings.push((\"player1\".to_string(), 1500))?;\n+ tf2_rankings.push((\"player2\".to_string(), 1400))?;\n+ tf2_rankings.push((\"player3\".to_string(), 1300))?;\n+ \n+ println!(\"✅ Added TF2 rankings using the docs pattern!\");\n+ \n+ // Add some other games for comparison\n+ println!(\"\\n📊 Adding CS2 rankings...\");\n+ let mut cs2_rankings = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ cs2_rankings.push((\"pro_player\".to_string(), 2500))?;\n+ cs2_rankings.push((\"skilled_gamer\".to_string(), 2200))?;\n+ \n+ println!(\"✅ Added CS2 rankings!\");\n+ \n+ // Now read back the data\n+ println!(\"\\n🏆 Current TF2 Leaderboard:\");\n+ let tf2_data = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ \n+ // Convert to vec and sort for display\n+ let mut tf2_leaderboard = tf2_data.to_vec()?;\n+ tf2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by score descending\n+ \n+ for (rank, (player, score)) in tf2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n🏆 Current CS2 Leaderboard:\");\n+ let cs2_data = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ \n+ let mut cs2_leaderboard = cs2_data.to_vec()?;\n+ cs2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1));\n+ \n+ for (rank, (player, score)) in cs2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n📈 Database Statistics:\");\n+ println!(\" TF2 has {} players\", tf2_data.len()?);\n+ println!(\" CS2 has {} players\", cs2_data.len()?);\n+ \n+ println!(\"\\n✨ This demonstrates the exact pattern from docs/motivation.md:\");\n+ println!(\" rankings.entry(game_mode)?.or_default()?.push((player, score))?;\");\n+ println!(\" \");\n+ println!(\" Compare this to the manual key construction required with raw KV stores:\");\n+ println!(\" let key = format!(\\\"leaderboard:{{}}:{{}}:player:{{}}\\\", game_mode, day, player_id);\");\n+ println!(\" db.insert(key.as_bytes(), score.to_le_bytes())?;\");\n+ println!(\" \");\n+ println!(\" Durable provides the ergonomic, type-safe abstraction over RocksDB!\");\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/streaming_demo.rs b/durable/examples/streaming_demo.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3a74a5318675f94f89aaf01562a5b28f8a1b664a\n--- /dev/null\n+++ b/durable/examples/streaming_demo.rs\n@@ -0,0 +1,81 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"streaming_demo_db\")?;\n+ \n+ // Create collections with a moderate amount of data\n+ let mut map = DurableMap::::new(&db, \"large_map\")?;\n+ let mut vec = DurableVec::::new(&db, \"large_vec\")?;\n+ \n+ println!(\"🚀 Streaming Iterator Demo\\n\");\n+ \n+ // Add 1000 entries to demonstrate streaming\n+ println!(\"Adding 1000 entries to map and vec...\");\n+ for i in 0..1000 {\n+ map.insert(i, format!(\"Value {}\", i))?;\n+ vec.push(format!(\"Item {}\", i))?;\n+ }\n+ \n+ println!(\"\\n📊 Collection sizes:\");\n+ println!(\" Map entries: {}\", map.len()?);\n+ println!(\" Vec elements: {}\", vec.len()?);\n+ \n+ // Demonstrate streaming iteration - memory efficient\n+ println!(\"\\n✨ Streaming iteration (memory efficient):\");\n+ \n+ // Count items without loading into memory\n+ let map_count = map.iter().count();\n+ println!(\" Counted {} map entries without loading into memory\", map_count);\n+ \n+ // Find specific items efficiently\n+ let target = 500;\n+ let found = map.iter()\n+ .find(|item| {\n+ item.as_ref()\n+ .map(|(k, _)| *k == target)\n+ .unwrap_or(false)\n+ });\n+ \n+ if let Some(Ok((k, v))) = found {\n+ println!(\" Found key {} with value '{}' via streaming\", k, v);\n+ }\n+ \n+ // Process only what we need\n+ println!(\"\\n🎯 Processing first 10 items only:\");\n+ for (i, item) in vec.iter()?.take(10).enumerate() {\n+ match item {\n+ Ok(value) => println!(\" [{}] {}\", i, value),\n+ Err(e) => println!(\" [{}] Error: {:?}\", i, e),\n+ }\n+ }\n+ \n+ // Filter and process without loading all data\n+ println!(\"\\n🔍 Filtering even keys without loading all data:\");\n+ let even_count = map.keys()\n+ .filter(|item| {\n+ item.as_ref()\n+ .map(|k| k % 2 == 0)\n+ .unwrap_or(false)\n+ })\n+ .count();\n+ println!(\" Found {} even keys\", even_count);\n+ \n+ // Compare with loading everything into memory\n+ println!(\"\\n⚠️ Loading all data into memory (less efficient for large collections):\");\n+ let all_values = map.values_vec()?;\n+ println!(\" Loaded {} values into a Vec\", all_values.len());\n+ \n+ println!(\"\\n✅ Streaming iterators provide:\");\n+ println!(\" • Constant memory usage regardless of collection size\");\n+ println!(\" • Ability to process data larger than RAM\");\n+ println!(\" • Early termination when finding specific items\");\n+ println!(\" • Efficient filtering and transformation\");\n+ \n+ // Clean up\n+ drop(map);\n+ drop(vec);\n+ drop(db);\n+ std::fs::remove_dir_all(\"streaming_demo_db\").ok();\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/vec_example.rs b/durable/examples/vec_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..07d394b5f3964ea1942a645c2f008e9e3c37d6d8\n--- /dev/null\n+++ b/durable/examples/vec_example.rs\n@@ -0,0 +1,66 @@\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent vector of tasks\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Add some tasks\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build Durable library\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 2,\n+ title: \"Write comprehensive tests\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 3,\n+ title: \"Create documentation\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ // Iterate through all tasks\n+ println!(\"\\nAll tasks:\");\n+ for (i, task) in tasks.iter()?.enumerate() {\n+ let task = task?;\n+ println!(\" [{}] {} - {}\", \n+ i, \n+ task.title, \n+ if task.completed { \"✓\" } else { \"○\" }\n+ );\n+ }\n+ \n+ // Get a specific task\n+ if let Some(task) = tasks.get(1)? {\n+ println!(\"\\nTask at index 1: {:?}\", task);\n+ }\n+ \n+ // Mark the last task as completed\n+ if let Some(mut last_task) = tasks.pop()? {\n+ println!(\"\\nCompleting task: {}\", last_task.title);\n+ last_task.completed = true;\n+ tasks.push(last_task)?;\n+ }\n+ \n+ // The data persists even after the program exits!\n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/repomix.config.json b/durable/repomix.config.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb52e4fcd383e8937245bd62566db8b7652402c2\n--- /dev/null\n+++ b/durable/repomix.config.json\n@@ -0,0 +1,27 @@\n+{\n+ \"output\": {\n+ \"filePath\": \"all.txt\",\n+ \"style\": \"plain\",\n+ \"parsableStyle\": false,\n+ \"fileSummary\": true,\n+ \"directoryStructure\": true,\n+ \"removeComments\": false,\n+ \"removeEmptyLines\": false,\n+ \"compress\": false,\n+ \"topFilesLength\": 100,\n+ \"showLineNumbers\": false,\n+ \"copyToClipboard\": false\n+ },\n+ \"include\": [],\n+ \"ignore\": {\n+ \"useGitignore\": true,\n+ \"useDefaultPatterns\": true,\n+ \"customPatterns\": []\n+ },\n+ \"security\": {\n+ \"enableSecurityCheck\": true\n+ },\n+ \"tokenCount\": {\n+ \"encoding\": \"o200k_base\"\n+ }\n+}\ndiff --git a/durable/src/lib.rs b/durable/src/lib.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..94e8823be93be1176b0a4cdc9797181b9f8caf60\n--- /dev/null\n+++ b/durable/src/lib.rs\n@@ -0,0 +1,123 @@\n+//! Durable - RocksDB-backed persistent data structures for Rust\n+\n+use std::path::Path;\n+use std::sync::Arc;\n+use rocksdb::{DB as RocksDB, Options, WriteBatch};\n+use thiserror::Error;\n+\n+pub mod vec;\n+pub mod map;\n+pub use vec::DurableVec;\n+pub use map::DurableMap;\n+\n+/// Error types for Durable operations\n+#[derive(Error, Debug)]\n+pub enum DurableError {\n+ #[error(\"RocksDB error: {0}\")]\n+ RocksDB(#[from] rocksdb::Error),\n+ \n+ #[error(\"Serialization error: {0}\")]\n+ Serialization(#[from] bincode::Error),\n+ \n+ #[error(\"Key not found\")]\n+ KeyNotFound,\n+ \n+ #[error(\"Collection not found: {0}\")]\n+ CollectionNotFound(String),\n+ \n+ #[error(\"Data corruption: {0}\")]\n+ Corruption(String),\n+}\n+\n+pub type Result = std::result::Result;\n+\n+/// A trait for types that can be used as nested collections.\n+pub trait DurableCollection {\n+ /// Creates a new instance of the collection from a database handle\n+ /// and a pre-determined, unique key prefix.\n+ /// \n+ /// This is the key method that allows `DurableMap` to instantiate\n+ /// a nested collection handle.\n+ fn from_prefix(db: Db, prefix: Vec) -> Self;\n+}\n+\n+/// The main database handle\n+#[derive(Clone)]\n+pub struct Db {\n+ inner: Arc,\n+}\n+\n+impl Db {\n+ /// Opens or creates a durable database at the given path\n+ pub fn open>(path: P) -> Result {\n+ let mut opts = Options::default();\n+ opts.create_if_missing(true);\n+ opts.create_missing_column_families(true);\n+ \n+ let db = RocksDB::open(&opts, path)?;\n+ Ok(Db { \n+ inner: Arc::new(db),\n+ })\n+ }\n+ \n+ /// Create a new write batch for atomic operations\n+ pub fn batch(&self) -> Batch {\n+ Batch {\n+ db: self.clone(),\n+ inner: WriteBatch::default(),\n+ }\n+ }\n+ \n+ /// Get the underlying RocksDB handle (for advanced usage)\n+ pub(crate) fn rocks(&self) -> &RocksDB {\n+ &self.inner\n+ }\n+ \n+ /// Get a new unique collection ID for nested collections\n+ pub fn new_collection_id(&self) -> Result {\n+ let key = b\"__global_meta:next_collection_id\";\n+ \n+ // Get current value\n+ let current_bytes = self.rocks().get(key)?;\n+ let current_id = match current_bytes {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid collection ID bytes size\".into()));\n+ }\n+ let id_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID bytes\".into()))?;\n+ u64::from_le_bytes(id_bytes)\n+ }\n+ None => 0,\n+ };\n+ \n+ let next_id = current_id + 1;\n+ \n+ // Try to atomically update - use compare-and-swap semantics\n+ let mut batch = WriteBatch::default();\n+ batch.put(key, &next_id.to_le_bytes());\n+ \n+ // For now, just write it directly. In a real implementation,\n+ // we'd want proper compare-and-swap to handle concurrent access\n+ self.rocks().write(batch)?;\n+ self.rocks().flush_wal(true)?;\n+ \n+ Ok(current_id)\n+ }\n+}\n+\n+/// A write batch for atomic operations\n+pub struct Batch {\n+ db: Db,\n+ inner: WriteBatch,\n+}\n+\n+impl Batch {\n+ /// Commit all operations in this batch atomically\n+ pub fn commit(self) -> Result<()> {\n+ self.db.rocks().write(self.inner)?;\n+ self.db.rocks().flush_wal(true)?;\n+ Ok(())\n+ }\n+ \n+}\ndiff --git a/durable/src/map.rs b/durable/src/map.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2629857270b1be346a20fc67dec1cfc57c829403\n--- /dev/null\n+++ b/durable/src/map.rs\n@@ -0,0 +1,1073 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::{IteratorMode, WriteBatch, Direction};\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent map backed by RocksDB\n+pub struct DurableMap {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl DurableMap \n+where \n+ K: Serialize + for<'de> Deserialize<'de>,\n+ V: Serialize + for<'de> Deserialize<'de>,\n+{\n+ /// Create a new DurableMap with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ Ok(DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Insert a key-value pair into the map\n+ pub fn insert(&mut self, key: K, value: V) -> Result> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Get the old value if it exists\n+ let old_value = self.get(&key)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Write the new value\n+ let db_key = self.entry_key(&key_bytes);\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if old_value.is_none() {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(old_value)\n+ }\n+ \n+ /// Put a key-value pair into the map without returning the old value\n+ /// \n+ /// This is more efficient than `insert` when you don't need the old value,\n+ /// as it only checks for key existence without deserializing the value.\n+ pub fn put(&mut self, key: K, value: V) -> Result<()> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Check if this is a new key (without deserializing the value)\n+ let is_new = self.db.rocks().get_pinned(&db_key)?.is_none();\n+ \n+ // Write the new value\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if is_new {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get a value by key\n+ pub fn get(&self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Ok(None),\n+ }\n+ }\n+ \n+ /// Check if a key exists in the map\n+ pub fn contains_key(&self, key: &K) -> Result {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ Ok(self.db.rocks().get(&db_key)?.is_some())\n+ }\n+ \n+ /// Remove a key-value pair from the map\n+ pub fn remove(&mut self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Get the old value\n+ let old_value = match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Some(value)\n+ }\n+ None => None,\n+ };\n+ \n+ // Delete the key if it existed and update length\n+ if old_value.is_some() {\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the entry\n+ batch.delete(&db_key);\n+ \n+ // Update length\n+ let new_len = self.len()? - 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ }\n+ \n+ Ok(old_value)\n+ }\n+ \n+\n+ \n+ /// Clear all entries from the map\n+ pub fn clear(&mut self) -> Result<()> {\n+ let prefix = self.entry_prefix();\n+ let mut batch = WriteBatch::default();\n+ \n+ // Collect all keys to delete\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ for item in iter {\n+ let (key, _) = item?;\n+ if !key.starts_with(&prefix) {\n+ break;\n+ }\n+ batch.delete(&key);\n+ }\n+ \n+ // Reset length to 0\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Iterate over all key-value pairs using a streaming iterator\n+ pub fn iter(&self) -> MapIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ MapIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all key-value pairs into a Vec\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.iter() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all keys using a streaming iterator\n+ pub fn keys(&self) -> KeyIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ KeyIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all keys into a Vec\n+ /// \n+ /// Note: This loads all keys into memory. For large collections,\n+ /// prefer using `keys()` which streams elements.\n+ pub fn keys_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.keys() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all values using a streaming iterator\n+ pub fn values(&self) -> ValueIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ ValueIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all values into a Vec\n+ /// \n+ /// Note: This loads all values into memory. For large collections,\n+ /// prefer using `values()` which streams elements.\n+ pub fn values_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.values() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Insert multiple key-value pairs in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let current_len = self.len()?;\n+ let mut new_entries = 0;\n+ \n+ for (key, value) in iter {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Check if this is a new key\n+ if !self.contains_key(&key)? {\n+ new_entries += 1;\n+ }\n+ \n+ batch.put(&db_key, &value_bytes);\n+ }\n+ \n+ // Update length if we added new entries\n+ if new_entries > 0 {\n+ let new_len = current_len + new_entries;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+\n+ \n+ // Helper methods\n+ \n+ fn entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn entry_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.extend_from_slice(b\":entry:\");\n+ prefix\n+ }\n+}\n+\n+// Additional implementation block for methods that don't require serialization constraints\n+impl DurableMap {\n+ /// Create a new DurableMap for nested collections (no serialization constraints)\n+ pub fn new_nested(db: &Db, name: &str) -> Self {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Create a new DurableMap from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the number of entries in the map (unconstrained version for nested collections)\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the map is empty (unconstrained version for nested collections)\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+\n+ /// Get the entry key for nested collections (needed by entry API)\n+ pub(crate) fn make_entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableMap\n+// This implementation is used for nested collections and doesn't require\n+// serialization bounds since nested maps use collection markers, not direct serialization\n+impl DurableCollection for DurableMap {\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableMap::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Entry API for DurableMap with nested collections\n+pub enum DurableEntry<'a, K, V> {\n+ Occupied(OccupiedEntry<'a, K, V>),\n+ Vacant(VacantEntry<'a, K, V>),\n+}\n+\n+impl<'a, K, V> DurableEntry<'a, K, V>\n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Gets the collection, creating it if it doesn't exist\n+ pub fn or_default(self) -> Result {\n+ match self {\n+ DurableEntry::Occupied(entry) => entry.or_default(),\n+ DurableEntry::Vacant(entry) => entry.or_default(),\n+ }\n+ }\n+}\n+\n+/// Represents an entry that already exists\n+pub struct OccupiedEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key_bytes: Vec,\n+ value_marker: Vec, // The bytes read from RocksDB, e.g., [0x02, ...]\n+}\n+\n+impl<'a, K, V> OccupiedEntry<'a, K, V> \n+where\n+ V: DurableCollection,\n+{\n+ /// Gets a handle to the existing nested collection\n+ pub fn get(self) -> Result {\n+ // 1. Parse the collection_id from self.value_marker\n+ if self.value_marker.len() != 9 || self.value_marker[0] != 0x02 {\n+ return Err(DurableError::Corruption(\"Invalid collection marker\".into()));\n+ }\n+ \n+ let col_id_bytes: [u8; 8] = self.value_marker[1..9].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID\".into()))?;\n+ let col_id = u64::from_le_bytes(col_id_bytes);\n+\n+ // 2. Re-construct the unique prefix for the child collection\n+ let parent_key_prefix = self.map.make_entry_key(&self.key_bytes);\n+ let child_prefix = [parent_key_prefix.as_slice(), &[0x00], &col_id.to_le_bytes()].concat();\n+\n+ // 3. Create the collection handle using the trait method\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+ \n+ /// Gets a handle to the existing nested collection (same as get but consumes self)\n+ pub fn or_default(self) -> Result {\n+ self.get()\n+ }\n+}\n+\n+/// Represents a slot that is empty\n+pub struct VacantEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key: K, // The original key from the user\n+}\n+\n+impl<'a, K, V> VacantEntry<'a, K, V> \n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Inserts a new default collection and returns a handle to it\n+ pub fn or_default(self) -> Result {\n+ // 1. Atomically get a new unique ID for the collection\n+ let new_col_id = self.map.db.new_collection_id()?;\n+\n+ // 2. Create the marker value that points to our new collection\n+ let mut value_marker = vec![0x02_u8];\n+ value_marker.extend_from_slice(&new_col_id.to_le_bytes());\n+\n+ // 3. Get the key bytes and construct the full parent entry key\n+ let key_bytes = bincode::serialize(&self.key)?;\n+ let parent_db_key = self.map.make_entry_key(&key_bytes);\n+\n+ // 4. ATOMICALLY write the marker to the parent map\n+ self.map.db.rocks().put(&parent_db_key, &value_marker)?;\n+ self.map.db.rocks().flush_wal(true)?;\n+\n+ // 5. Construct the unique prefix for our new child collection\n+ let child_prefix = [parent_db_key.as_slice(), &[0x00], &new_col_id.to_le_bytes()].concat();\n+\n+ // 6. Create and return the new collection handle\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+}\n+\n+// API for nested collections\n+impl DurableMap {\n+ /// The entry point for creating or accessing a nested collection.\n+ /// This method is only available when `V` is a `DurableCollection`.\n+ pub fn entry(&self, key: K) -> Result>\n+ where\n+ V: DurableCollection,\n+ K: Serialize,\n+ {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let db_key = self.make_entry_key(&key_bytes);\n+\n+ match self.db.rocks().get(&db_key)? {\n+ Some(value_marker) => {\n+ // Key exists. The value should be a collection marker.\n+ Ok(DurableEntry::Occupied(OccupiedEntry {\n+ map: self,\n+ key_bytes,\n+ value_marker,\n+ }))\n+ }\n+ None => {\n+ // Key doesn't exist.\n+ Ok(DurableEntry::Vacant(VacantEntry { map: self, key }))\n+ }\n+ }\n+ }\n+}\n+\n+/// Iterator over key-value pairs in a DurableMap\n+pub struct MapIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for MapIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result<(K, V)>;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key and value\n+ match (bincode::deserialize(key_bytes), bincode::deserialize(&value_bytes)) {\n+ (Ok(key), Ok(value)) => Some(Ok((key, value))),\n+ (Err(e), _) | (_, Err(e)) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over keys in a DurableMap\n+pub struct KeyIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for KeyIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, _))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key\n+ match bincode::deserialize(key_bytes) {\n+ Ok(key) => Some(Ok(key)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over values in a DurableMap\n+pub struct ValueIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for ValueIterator<'a, K, V>\n+where\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Deserialize value\n+ match bincode::deserialize(&value_bytes) {\n+ Ok(value) => Some(Ok(value)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_insert_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"test_map\").unwrap();\n+ \n+ // Insert some values\n+ assert_eq!(map.insert(\"one\".to_string(), 1).unwrap(), None);\n+ assert_eq!(map.insert(\"two\".to_string(), 2).unwrap(), None);\n+ assert_eq!(map.insert(\"three\".to_string(), 3).unwrap(), None);\n+ \n+ // Get values\n+ assert_eq!(map.get(&\"one\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"three\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.get(&\"four\".to_string()).unwrap(), None);\n+ \n+ // Update existing value\n+ assert_eq!(map.insert(\"two\".to_string(), 22).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(22));\n+ }\n+ \n+ #[test]\n+ fn test_remove() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert and remove\n+ map.insert(\"key\".to_string(), \"value\".to_string()).unwrap();\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), Some(\"value\".to_string()));\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), None);\n+ assert_eq!(map.get(&\"key\".to_string()).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_contains_key() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"contains_map\").unwrap();\n+ \n+ map.insert(42, \"answer\".to_string()).unwrap();\n+ \n+ assert!(map.contains_key(&42).unwrap());\n+ assert!(!map.contains_key(&43).unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_len_and_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"len_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Add items\n+ for i in 0..10 {\n+ map.insert(i, i * 2).unwrap();\n+ }\n+ assert_eq!(map.len().unwrap(), 10);\n+ assert!(!map.is_empty().unwrap());\n+ \n+ // Clear\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate map\n+ {\n+ let mut map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ map.insert(\"binary\".to_string(), vec![1, 2, 3, 4, 5]).unwrap();\n+ map.insert(\"data\".to_string(), vec![10, 20, 30]).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ \n+ assert_eq!(map.get(&\"binary\".to_string()).unwrap(), Some(vec![1, 2, 3, 4, 5]));\n+ assert_eq!(map.get(&\"data\".to_string()).unwrap(), Some(vec![10, 20, 30]));\n+ assert_eq!(map.len().unwrap(), 2);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"iter_map\").unwrap();\n+ \n+ // Insert data\n+ let data = vec![\n+ (\"apple\".to_string(), 1),\n+ (\"banana\".to_string(), 2),\n+ (\"cherry\".to_string(), 3),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test iter()\n+ let mut items = map.to_vec().unwrap();\n+ items.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(items, data);\n+ \n+ // Test keys()\n+ let mut keys = map.keys_vec().unwrap();\n+ keys.sort();\n+ assert_eq!(keys, vec![\"apple\", \"banana\", \"cherry\"]);\n+ \n+ // Test values()\n+ let mut values = map.values_vec().unwrap();\n+ values.sort();\n+ assert_eq!(values, vec![1, 2, 3]);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"extend_map\").unwrap();\n+ \n+ // Extend from iterator\n+ let data: HashMap = vec![\n+ (1, \"one\".to_string()),\n+ (2, \"two\".to_string()),\n+ (3, \"three\".to_string()),\n+ ].into_iter().collect();\n+ \n+ map.extend(data.clone()).unwrap();\n+ \n+ // Verify all items were inserted\n+ for (k, v) in data {\n+ assert_eq!(map.get(&k).unwrap(), Some(v));\n+ }\n+ assert_eq!(map.len().unwrap(), 3);\n+ }\n+ \n+ #[test]\n+ fn test_complex_keys() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n+ struct ComplexKey {\n+ id: u64,\n+ name: String,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"complex_map\").unwrap();\n+ \n+ let key1 = ComplexKey { id: 1, name: \"first\".to_string() };\n+ let key2 = ComplexKey { id: 2, name: \"second\".to_string() };\n+ \n+ map.insert(key1.clone(), \"value1\".to_string()).unwrap();\n+ map.insert(key2.clone(), \"value2\".to_string()).unwrap();\n+ \n+ assert_eq!(map.get(&key1).unwrap(), Some(\"value1\".to_string()));\n+ assert_eq!(map.get(&key2).unwrap(), Some(\"value2\".to_string()));\n+ }\n+ \n+ #[test]\n+ fn test_multiple_maps_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ let mut map1 = DurableMap::::new(&db, \"map1\").unwrap();\n+ let mut map2 = DurableMap::::new(&db, \"map2\").unwrap();\n+ \n+ // Insert different data\n+ map1.insert(\"shared_key\".to_string(), 100).unwrap();\n+ map2.insert(\"shared_key\".to_string(), 200).unwrap();\n+ \n+ // Verify isolation\n+ assert_eq!(map1.get(&\"shared_key\".to_string()).unwrap(), Some(100));\n+ assert_eq!(map2.get(&\"shared_key\".to_string()).unwrap(), Some(200));\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterators() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"stream_map\").unwrap();\n+ \n+ // Insert test data\n+ let data = vec![\n+ (\"alice\".to_string(), 100),\n+ (\"bob\".to_string(), 200),\n+ (\"charlie\".to_string(), 300),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in map.iter() {\n+ let (k, v) = item.unwrap();\n+ collected.push((k, v));\n+ }\n+ collected.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(collected, data);\n+ \n+ // Test keys iterator\n+ let mut keys = Vec::new();\n+ for key in map.keys() {\n+ keys.push(key.unwrap());\n+ }\n+ keys.sort();\n+ assert_eq!(keys, vec![\"alice\", \"bob\", \"charlie\"]);\n+ \n+ // Test values iterator\n+ let mut values = Vec::new();\n+ for value in map.values() {\n+ values.push(value.unwrap());\n+ }\n+ values.sort();\n+ assert_eq!(values, vec![100, 200, 300]);\n+ \n+ // Test that iterators properly handle prefix boundaries\n+ let mut map2 = DurableMap::::new(&db, \"stream_map2\").unwrap();\n+ map2.insert(\"dave\".to_string(), 400).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = map.iter().map(Result::unwrap).collect();\n+ let collected2: Vec<_> = map2.iter().map(Result::unwrap).collect();\n+ \n+ assert_eq!(collected1.len(), 3);\n+ assert_eq!(collected2.len(), 1);\n+ assert_eq!(collected2[0], (\"dave\".to_string(), 400));\n+ }\n+ \n+ #[test]\n+ fn test_metadata_length_tracking() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"length_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Insert operations should update length\n+ map.insert(\"key1\".to_string(), \"value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ map.insert(\"key2\".to_string(), \"value2\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Updating existing key should not change length\n+ map.insert(\"key1\".to_string(), \"new_value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Remove operations should update length\n+ map.remove(&\"key1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Removing non-existent key should not change length\n+ map.remove(&\"non_existent\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Extend should update length correctly\n+ let data = vec![\n+ (\"key3\".to_string(), \"value3\".to_string()),\n+ (\"key4\".to_string(), \"value4\".to_string()),\n+ (\"key5\".to_string(), \"value5\".to_string()),\n+ ];\n+ map.extend(data).unwrap();\n+ assert_eq!(map.len().unwrap(), 4); // key2 + 3 new keys\n+ \n+ // Extend with existing keys should only count new ones\n+ let mixed_data = vec![\n+ (\"key2\".to_string(), \"updated_value2\".to_string()), // existing\n+ (\"key6\".to_string(), \"value6\".to_string()), // new\n+ ];\n+ map.extend(mixed_data).unwrap();\n+ assert_eq!(map.len().unwrap(), 5); // only key6 was new\n+ \n+ // Clear should reset length to 0\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_put_method() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"put_map\").unwrap();\n+ \n+ // Put new entries\n+ map.put(\"a\".to_string(), 1).unwrap();\n+ map.put(\"b\".to_string(), 2).unwrap();\n+ map.put(\"c\".to_string(), 3).unwrap();\n+ \n+ // Verify entries exist and length is correct\n+ assert_eq!(map.get(&\"a\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"c\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.len().unwrap(), 3);\n+ \n+ // Update existing entry with put\n+ map.put(\"b\".to_string(), 20).unwrap();\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(20));\n+ assert_eq!(map.len().unwrap(), 3); // Length should not change\n+ \n+ // Compare put vs insert performance characteristics\n+ // put() doesn't return old value but is more efficient\n+ map.put(\"d\".to_string(), 4).unwrap();\n+ assert_eq!(map.len().unwrap(), 4);\n+ \n+ // insert() returns old value\n+ let old = map.insert(\"d\".to_string(), 40).unwrap();\n+ assert_eq!(old, Some(4));\n+ assert_eq!(map.len().unwrap(), 4);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))]\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_insert_get_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"prop_map\").unwrap();\n+ \n+ // Insert all pairs\n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Verify all can be retrieved\n+ for (k, v) in &data {\n+ prop_assert_eq!(map.get(k).unwrap(), Some(*v));\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(map.len().unwrap(), data.len());\n+ }\n+ \n+ #[test]\n+ fn prop_remove_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert all\n+ map.extend(data.clone()).unwrap();\n+ \n+ // Remove all and verify\n+ for (k, v) in data {\n+ prop_assert_eq!(map.remove(&k).unwrap(), Some(v));\n+ prop_assert_eq!(map.remove(&k).unwrap(), None);\n+ prop_assert!(!map.contains_key(&k).unwrap());\n+ }\n+ \n+ prop_assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"clear_map\").unwrap();\n+ \n+ map.extend(data).unwrap();\n+ map.clear().unwrap();\n+ \n+ prop_assert_eq!(map.len().unwrap(), 0);\n+ prop_assert!(map.is_empty().unwrap());\n+ prop_assert_eq!(map.to_vec().unwrap(), vec![]);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_nested_collections() {\n+ use crate::DurableVec;\n+ \n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create a map where values are DurableVec\n+ let users_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Test creating nested collections through the entry API\n+ let mut alice_posts = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ alice_posts.push(101).unwrap();\n+ alice_posts.push(102).unwrap();\n+ alice_posts.push(103).unwrap();\n+ \n+ // Test accessing the same collection again\n+ let alice_posts_again = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(alice_posts_again.get(0).unwrap(), Some(101));\n+ assert_eq!(alice_posts_again.get(1).unwrap(), Some(102));\n+ assert_eq!(alice_posts_again.get(2).unwrap(), Some(103));\n+ \n+ // Test creating a different nested collection\n+ let mut bob_posts = users_posts.entry(\"bob\".to_string()).unwrap().or_default().unwrap();\n+ bob_posts.push(201).unwrap();\n+ bob_posts.push(202).unwrap();\n+ \n+ // Verify isolation between nested collections\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(bob_posts.len().unwrap(), 2);\n+ \n+ // Test chained calls\n+ users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap().push(301).unwrap();\n+ let charlie_posts = users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(charlie_posts.len().unwrap(), 1);\n+ assert_eq!(charlie_posts.get(0).unwrap(), Some(301));\n+ }\n+ \n+ // Note: Nested DurableMap-in-DurableMap requires implementing Serialize/Deserialize \n+ // for DurableMap, which is not straightforward since it contains database handles.\n+ // For now, let's focus on the more common case of Map-to-Vec nesting.\n+ \n+ // Deep nesting with Map -> Map -> Vec also requires DurableMap serialization\n+ // Let's skip this for now and focus on the fundamental Map -> Vec case\n+ \n+ #[test]\n+ fn test_nested_collection_persistence() {\n+ use crate::DurableVec;\n+ \n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create nested structure and populate it\n+ {\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ let mut user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ user1_data.push(\"data1\".to_string()).unwrap();\n+ user1_data.push(\"data2\".to_string()).unwrap();\n+ \n+ let mut user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ user2_data.push(\"other_data\".to_string()).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify persistence\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ \n+ let user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user1_data.len().unwrap(), 2);\n+ assert_eq!(user1_data.get(0).unwrap(), Some(\"data1\".to_string()));\n+ assert_eq!(user1_data.get(1).unwrap(), Some(\"data2\".to_string()));\n+ \n+ let user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user2_data.len().unwrap(), 1);\n+ assert_eq!(user2_data.get(0).unwrap(), Some(\"other_data\".to_string()));\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/src/vec.rs b/durable/src/vec.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..28d08fd0786df241aaf9c01b279708e547e4c034\n--- /dev/null\n+++ b/durable/src/vec.rs\n@@ -0,0 +1,658 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::WriteBatch;\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent vector backed by RocksDB\n+pub struct DurableVec {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl DurableVec \n+where \n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ /// Create a new DurableVec with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"vec:{}\", name).into_bytes();\n+ \n+ Ok(DurableVec {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Create a new DurableVec from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the length of the vector\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the vector is empty\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+ \n+ /// Push an element to the end of the vector\n+ pub fn push(&mut self, value: T) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Serialize the value\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Write the element\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ \n+ // Update the length\n+ let new_len = (len + 1) as u64;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &new_len.to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get an element at the given index\n+ pub fn get(&self, index: usize) -> Result> {\n+ let len = self.len()?;\n+ if index >= len {\n+ return Ok(None);\n+ }\n+ \n+ let key = self.element_key(index);\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Err(DurableError::Corruption(\n+ format!(\"Element at index {} not found but index < len\", index)\n+ )),\n+ }\n+ }\n+ \n+ /// Clear all elements from the vector\n+ pub fn clear(&mut self) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete all elements\n+ for i in 0..len {\n+ let key = self.element_key(i);\n+ batch.delete(&key);\n+ }\n+ \n+ // Delete the length meta key\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Create a streaming iterator over the vector\n+ pub fn iter(&self) -> Result> + '_> {\n+ let prefix = self.element_prefix();\n+ let iter = self.db.rocks().iterator(rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward));\n+ \n+ Ok(VecIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Convert the entire vector to a Vec in memory\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let len = self.len()?;\n+ let mut result = Vec::with_capacity(len);\n+ \n+ for item in self.iter()? {\n+ result.push(item?);\n+ }\n+ \n+ Ok(result)\n+ }\n+ \n+ /// Push multiple elements in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let mut len = self.len()?;\n+ \n+ for value in iter {\n+ let value_bytes = bincode::serialize(&value)?;\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ len += 1;\n+ }\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Remove and return the last element\n+ pub fn pop(&mut self) -> Result> {\n+ let len = self.len()?;\n+ if len == 0 {\n+ return Ok(None);\n+ }\n+ \n+ let last_idx = len - 1;\n+ let value = self.get(last_idx)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the last element\n+ let elem_key = self.element_key(last_idx);\n+ batch.delete(&elem_key);\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(last_idx as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(value)\n+ }\n+ \n+ // Helper methods\n+ \n+ fn element_key(&self, index: usize) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.push(b':');\n+ key.extend_from_slice(&(index as u64).to_be_bytes());\n+ key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+ \n+ fn element_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.push(b':');\n+ prefix\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableVec\n+impl DurableCollection for DurableVec \n+where\n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableVec::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Iterator over a DurableVec\n+pub struct VecIterator<'a, T> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl<'a, T> Iterator for VecIterator<'a, T>\n+where\n+ T: for<'de> Deserialize<'de>\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ loop {\n+ match self.inner.next() {\n+ Some(Ok((key, value))) => {\n+ // Check if we're still within our prefix\n+ if !key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Check if this is a meta key (skip it)\n+ // The key pattern is: prefix:element_index or prefix:__meta:type\n+ // We want to skip any key that contains \"__meta:\"\n+ if key.windows(7).any(|w| w == b\"__meta:\") {\n+ continue; // Skip this key and try the next one\n+ }\n+ \n+ // Deserialize the value\n+ match bincode::deserialize(&value) {\n+ Ok(item) => return Some(Ok(item)),\n+ Err(e) => return Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => return Some(Err(e.into())),\n+ None => return None,\n+ }\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_push_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"test_vec\").unwrap();\n+ \n+ // Push some values\n+ vec.push(\"first\".to_string()).unwrap();\n+ vec.push(\"second\".to_string()).unwrap();\n+ vec.push(\"third\".to_string()).unwrap();\n+ \n+ // Check length\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Get values\n+ assert_eq!(vec.get(0).unwrap(), Some(\"first\".to_string()));\n+ assert_eq!(vec.get(1).unwrap(), Some(\"second\".to_string()));\n+ assert_eq!(vec.get(2).unwrap(), Some(\"third\".to_string()));\n+ assert_eq!(vec.get(3).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate vector\n+ {\n+ let mut vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ vec.push(42).unwrap();\n+ vec.push(100).unwrap();\n+ vec.push(-7).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ \n+ assert_eq!(vec.len().unwrap(), 3);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ assert_eq!(vec.get(1).unwrap(), Some(100));\n+ assert_eq!(vec.get(2).unwrap(), Some(-7));\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add some elements\n+ vec.extend(vec![1, 2, 3, 4, 5]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ \n+ // Should be able to push again\n+ vec.push(42).unwrap();\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ }\n+ \n+ #[test]\n+ fn test_pop() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Empty pop\n+ assert_eq!(vec.pop().unwrap(), None);\n+ \n+ // Push and pop\n+ vec.push(\"a\".to_string()).unwrap();\n+ vec.push(\"b\".to_string()).unwrap();\n+ vec.push(\"c\".to_string()).unwrap();\n+ \n+ assert_eq!(vec.pop().unwrap(), Some(\"c\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 2);\n+ assert_eq!(vec.pop().unwrap(), Some(\"b\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.pop().unwrap(), Some(\"a\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"iter_vec\").unwrap();\n+ \n+ // Add elements\n+ let values = vec![10, 20, 30, 40, 50];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Iterate and collect\n+ let collected = vec.to_vec().unwrap();\n+ \n+ assert_eq!(collected, values);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with iterator\n+ vec.extend(vec![\"a\", \"b\", \"c\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Extend again\n+ vec.extend(vec![\"d\", \"e\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Verify all elements\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![\"a\", \"b\", \"c\", \"d\", \"e\"]);\n+ }\n+ \n+ #[test]\n+ fn test_large_dataset() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"large_vec\").unwrap();\n+ \n+ // Push many elements\n+ let count = 1000;\n+ for i in 0..count {\n+ vec.push(i).unwrap();\n+ }\n+ \n+ assert_eq!(vec.len().unwrap(), count as usize);\n+ \n+ // Verify some random accesses\n+ assert_eq!(vec.get(0).unwrap(), Some(0));\n+ assert_eq!(vec.get(500).unwrap(), Some(500));\n+ assert_eq!(vec.get(999).unwrap(), Some(999));\n+ assert_eq!(vec.get(1000).unwrap(), None);\n+ \n+ // Verify iteration count\n+ let all_values = vec.to_vec().unwrap();\n+ assert_eq!(all_values.len(), count as usize);\n+ }\n+ \n+ #[test] \n+ fn test_complex_types() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+ struct User {\n+ id: u64,\n+ name: String,\n+ email: String,\n+ active: bool,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"users\").unwrap();\n+ \n+ let user1 = User {\n+ id: 1,\n+ name: \"Alice\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ active: true,\n+ };\n+ \n+ let user2 = User {\n+ id: 2,\n+ name: \"Bob\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ active: false,\n+ };\n+ \n+ vec.push(user1.clone()).unwrap();\n+ vec.push(user2.clone()).unwrap();\n+ \n+ assert_eq!(vec.get(0).unwrap(), Some(user1));\n+ assert_eq!(vec.get(1).unwrap(), Some(user2));\n+ }\n+ \n+ #[test]\n+ fn test_empty_vec_operations() {\n+ let (_temp, db) = setup_test_db();\n+ let vec = DurableVec::::new(&db, \"empty_vec\").unwrap();\n+ \n+ // Test operations on empty vec\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ assert_eq!(vec.get(0).unwrap(), None);\n+ assert_eq!(vec.get(100).unwrap(), None);\n+ assert_eq!(vec.to_vec().unwrap(), Vec::::new());\n+ }\n+ \n+ #[test]\n+ fn test_multiple_vecs_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create multiple vectors with different names\n+ let mut vec1 = DurableVec::::new(&db, \"vec1\").unwrap();\n+ let mut vec2 = DurableVec::::new(&db, \"vec2\").unwrap();\n+ \n+ // Push different data to each\n+ vec1.push(\"vec1_data\".to_string()).unwrap();\n+ vec2.push(\"vec2_data\".to_string()).unwrap();\n+ \n+ // Verify they don't interfere\n+ assert_eq!(vec1.get(0).unwrap(), Some(\"vec1_data\".to_string()));\n+ assert_eq!(vec2.get(0).unwrap(), Some(\"vec2_data\".to_string()));\n+ assert_eq!(vec1.len().unwrap(), 1);\n+ assert_eq!(vec2.len().unwrap(), 1);\n+ }\n+ \n+ #[test]\n+ fn test_batch_atomicity() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"batch_vec\").unwrap();\n+ \n+ // Add initial data\n+ vec.push(1).unwrap();\n+ vec.push(2).unwrap();\n+ vec.push(3).unwrap();\n+ \n+ // Verify initial state\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Clear should be atomic - either all elements deleted or none\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ \n+ // Extend should be atomic - either all elements added or none\n+ vec.extend(vec![10, 20, 30, 40, 50]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![10, 20, 30, 40, 50]);\n+ }\n+ \n+ #[test]\n+ fn test_unicode_strings() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"unicode_vec\").unwrap();\n+ \n+ let test_strings = vec![\n+ \"Hello, 世界!\".to_string(),\n+ \"🦀 Rust 🚀\".to_string(),\n+ \"Ñoño\".to_string(),\n+ \"🏴‍☠️ Pirates\".to_string(),\n+ ];\n+ \n+ vec.extend(test_strings.clone()).unwrap();\n+ \n+ let retrieved = vec.to_vec().unwrap();\n+ assert_eq!(retrieved, test_strings);\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterator() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"stream_vec\").unwrap();\n+ \n+ // Add test data\n+ let values = vec![1, 2, 3, 4, 5];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in vec.iter().unwrap() {\n+ collected.push(item.unwrap());\n+ }\n+ \n+ assert_eq!(collected, values);\n+ \n+ // Test that iterator properly handles prefix boundaries\n+ let mut vec2 = DurableVec::::new(&db, \"stream_vec2\").unwrap();\n+ vec2.extend(vec![10, 20, 30]).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = vec.iter().unwrap().collect::>>().unwrap();\n+ let collected2: Vec<_> = vec2.iter().unwrap().collect::>>().unwrap();\n+ \n+ assert_eq!(collected1, values);\n+ assert_eq!(collected2, vec![10, 20, 30]);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))] // Skip proptest under miri\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_push_get_consistency(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"prop_vec\").unwrap();\n+ \n+ // Push all values\n+ for value in &values {\n+ vec.push(*value).unwrap();\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ \n+ // Verify all values can be retrieved correctly\n+ for (i, expected) in values.iter().enumerate() {\n+ prop_assert_eq!(vec.get(i).unwrap(), Some(*expected));\n+ }\n+ }\n+ \n+ #[test]\n+ fn prop_extend_iter_roundtrip(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Get back via iteration\n+ let retrieved = vec.to_vec().unwrap();\n+ \n+ prop_assert_eq!(retrieved, values);\n+ }\n+ \n+ #[test]\n+ fn prop_pop_removes_last(mut values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Add all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Pop values and verify\n+ while let Some(expected) = values.pop() {\n+ let popped = vec.pop().unwrap();\n+ prop_assert_eq!(popped, Some(expected));\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ }\n+ \n+ // Vector should be empty\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add values\n+ vec.extend(values).unwrap();\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ \n+ // Should be empty\n+ prop_assert_eq!(vec.len().unwrap(), 0);\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.get(0).unwrap(), None);\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/todo.tdsl b/durable/todo.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6c9df9cbb25bb72d17aac07296a0c935ccbe89e7\n--- /dev/null\n+++ b/durable/todo.tdsl\n@@ -0,0 +1,4 @@\n+Add streaming iterators to avoid loading entire collections\n+Implement collection nesting (e.g., DurableMap>)\n+Add benchmarks to measure performance\n+Implement schema versioning and migrations\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7..34672bd7acb9d165eea290e779c548fa4d2b8e3d 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -22,6 +22,7 @@ async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n rand = \"0.8\"\n urlencoding = \"2\"\n+durable = { path = \"../durable\" }\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/entity_store.rs b/server/src/entity_store.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..be3300529e97801a4d42f06f3f669b78ddd6856d\n--- /dev/null\n+++ b/server/src/entity_store.rs\n@@ -0,0 +1,90 @@\n+//! Off-heap storage for full entity payloads (Reddit API JSON).\n+//!\n+//! Derived [`crate::reducer::EntityData`] stays in the in-memory tree; raw JSON\n+//! lives in RocksDB via the workspace `durable` crate.\n+\n+use std::path::Path;\n+use std::sync::{Arc, Mutex};\n+\n+use durable::{Db, DurableMap};\n+use serde_json::Value;\n+\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EntityStoreError {\n+ #[error(\"durable error: {0}\")]\n+ Durable(#[from] durable::DurableError),\n+ #[error(\"json error: {0}\")]\n+ Json(#[from] serde_json::Error),\n+ #[error(\"io error: {0}\")]\n+ Io(#[from] std::io::Error),\n+ #[error(\"store lock poisoned\")]\n+ Poisoned,\n+}\n+\n+struct EntityStoreInner {\n+ _db: Db,\n+ payloads: DurableMap,\n+}\n+\n+/// Disk-backed map of entity id → raw JSON payload.\n+#[derive(Clone)]\n+pub struct EntityStore {\n+ inner: Arc>,\n+}\n+\n+impl EntityStore {\n+ /// Open (or create) the entity database under `dir`.\n+ pub fn open(dir: &Path) -> Result {\n+ std::fs::create_dir_all(dir)?;\n+ let db = Db::open(dir)?;\n+ let payloads = DurableMap::new(&db, \"entity_payloads\")?;\n+ Ok(Self {\n+ inner: Arc::new(Mutex::new(EntityStoreInner { _db: db, payloads })),\n+ })\n+ }\n+\n+ /// Persist a payload for `id` (overwrites any existing entry).\n+ pub fn put(&self, id: &ItemId, payload: &Value) -> Result<(), EntityStoreError> {\n+ let json = serde_json::to_string(payload)?;\n+ let mut inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ inner\n+ .payloads\n+ .put(id.as_str().to_string(), json)\n+ .map_err(EntityStoreError::from)\n+ }\n+\n+ /// Load a stored payload, if present.\n+ pub fn get(&self, id: &ItemId) -> Result, EntityStoreError> {\n+ let inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ match inner.payloads.get(&id.as_str().to_string())? {\n+ Some(json) => Ok(Some(serde_json::from_str(&json)?)),\n+ None => Ok(None),\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde_json::json;\n+\n+ #[test]\n+ fn round_trip_payload() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let store = EntityStore::open(tmp.path()).unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let payload = json!({\"kind\": \"t5\", \"data\": {\"display_name\": \"rust\"}});\n+\n+ store.put(&id, &payload).unwrap();\n+ let loaded = store.get(&id).unwrap().unwrap();\n+ assert_eq!(loaded, payload);\n+ }\n+}\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nindex 03c86f7f028cdf167b4943951d724da486b1b82c..838c8830b265f28a03442681575271eef539508c 100644\n--- a/server/src/event_log.rs\n+++ b/server/src/event_log.rs\n@@ -7,6 +7,12 @@ use tokio::{\n \n use crate::events::Event;\n \n+#[derive(Debug, Default)]\n+pub struct ReplayStats {\n+ pub applied: usize,\n+ pub bad_lines: usize,\n+}\n+\n #[derive(Debug, thiserror::Error)]\n pub enum EventLogError {\n #[error(\"io error: {0}\")]\n@@ -51,30 +57,87 @@ impl EventLog {\n Ok(())\n }\n \n- pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ /// Stream the log one line at a time — parse each [`Event`], apply, drop before the next line.\n+ pub async fn replay(&self, mut apply: F) -> Result\n+ where\n+ F: FnMut(Event) -> Result<(), EventLogError>,\n+ {\n+ let mut stats = ReplayStats::default();\n if !fs::try_exists(&self.path).await? {\n- return Ok((vec![], vec![]));\n+ return Ok(stats);\n }\n \n let f = fs::File::open(&self.path).await?;\n let mut reader = BufReader::new(f).lines();\n \n- let mut events = Vec::new();\n- let mut bad_lines = Vec::new();\n-\n- let mut line_no: usize = 0;\n while let Some(line) = reader.next_line().await? {\n- line_no += 1;\n let trimmed = line.trim();\n if trimmed.is_empty() {\n continue;\n }\n match serde_json::from_str::(trimmed) {\n- Ok(ev) => events.push(ev),\n- Err(_) => bad_lines.push((line_no, line)),\n+ Ok(ev) => match apply(ev) {\n+ Ok(()) => stats.applied += 1,\n+ Err(e) => return Err(e),\n+ },\n+ Err(_) => stats.bad_lines += 1,\n }\n }\n \n- Ok((events, bad_lines))\n+ Ok(stats)\n+ }\n+\n+ /// Load every event into memory. Prefer [`Self::replay`] for startup.\n+ pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ let mut events = Vec::new();\n+ let stats = self\n+ .replay(|ev| {\n+ events.push(ev);\n+ Ok(())\n+ })\n+ .await?;\n+ let _ = stats;\n+ Ok((events, vec![]))\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::events::Event;\n+\n+ #[tokio::test]\n+ async fn replay_applies_one_line_at_a_time() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(&path);\n+ log.append(&Event::NodeEnsured {\n+ id: \"reddit.com/r/rust\".into(),\n+ })\n+ .await\n+ .unwrap();\n+ log.append(&Event::VoteRecorded {\n+ ts: 1,\n+ a: \"a\".into(),\n+ b: \"b\".into(),\n+ ratio_left: 2,\n+ ratio_right: 1,\n+ scope: String::new(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut seen = Vec::new();\n+ let stats = log\n+ .replay(|ev| {\n+ seen.push(ev);\n+ Ok(())\n+ })\n+ .await\n+ .unwrap();\n+\n+ assert_eq!(stats.applied, 2);\n+ assert_eq!(stats.bad_lines, 0);\n+ assert_eq!(seen.len(), 2);\n }\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 7f7e28c8ac3758de87f1f8e073b24be4132d38da..c9440a1019cb05c410eeb07b0b3ba09e4c6a6c6a 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -1,4 +1,5 @@\n pub mod api;\n+pub mod entity_store;\n pub mod event_log;\n pub mod events;\n pub mod fetch;\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 626454e5a2f638734193b5190a2beea286af85b6..28fcb83a2dfef01c76b802cec850624197b4b686 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -10,6 +10,7 @@ use serde_json::Value;\n use tokio::sync::{mpsc, oneshot, RwLock};\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n fetch::now_ms,\n@@ -80,6 +81,7 @@ impl RedditBroker {\n pub fn spawn(\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n config: RedditApiConfig,\n ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n@@ -104,7 +106,7 @@ impl RedditBroker {\n \"reddit worker started\"\n );\n \n- tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, entity_store, client, config));\n \n Self { tx }\n }\n@@ -193,9 +195,16 @@ pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option Result<(), String> {\n let view = entity_view_from_payload(id, &payload);\n- tree.apply_entity_raw(id, payload, view);\n+ store.put(id, &payload).map_err(|e| e.to_string())?;\n+ tree.apply_entity(id, view);\n+ Ok(())\n }\n \n fn notify(done: Option>, result: FetchJobResult) {\n@@ -208,6 +217,7 @@ async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n client: Client,\n config: RedditApiConfig,\n ) {\n@@ -302,14 +312,16 @@ async fn reddit_worker(\n let mut tree = tree.write().await;\n if kind == FetchKind::Children {\n let view = entity_view_from_payload(&child_id, &child_payload);\n- tree.apply_entity_under_parent(\n- &fetch_id,\n- &child_id,\n- child_payload,\n- view,\n- );\n- } else {\n- apply_entity_import(&mut tree, &child_id, child_payload);\n+ if let Err(e) = entity_store.put(&child_id, &child_payload) {\n+ write_err = Some(e.to_string());\n+ break;\n+ }\n+ tree.apply_entity_under_parent(&fetch_id, &child_id, view);\n+ } else if let Err(e) =\n+ apply_entity_import(&mut tree, &entity_store, &child_id, child_payload)\n+ {\n+ write_err = Some(e);\n+ break;\n }\n }\n written += 1;\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex fc23f41137d7df33997afe19c533505c250cc305..cf00f235d02e487439f8767d532929fe329a0084 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,7 +1,6 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -134,9 +133,8 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n- /// Full imported API JSON (persisted in the event log).\n- pub entity_raw: Option,\n- /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n+ /// Domain-specific view derived from imported payload (e.g. Reddit title/author).\n+ /// Raw JSON lives in [`crate::entity_store::EntityStore`].\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -206,28 +204,25 @@ impl GlobalTree {\n }\n }\n \n- pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n+ pub fn apply_entity(&mut self, id: &ItemId, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n }\n \n- /// Import entity data for `id` and attach it as a direct child of `parent`\n+ /// Import entity view for `id` and attach it as a direct child of `parent`\n /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`\n /// parent rules pulling intermediate path segments into the subreddit).\n pub fn apply_entity_under_parent(\n &mut self,\n parent: &ItemId,\n id: &ItemId,\n- payload: Value,\n view: Option,\n ) {\n self.ensure_path(parent);\n self.ensure_node(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n if let Some(p) = self.nodes.get_mut(parent) {\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 4c3008e73cc74d8483a2064a0a280e73d82a01ec..dce04022de7ad9b0ffe0bac05ea79182b616945a 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -3,6 +3,7 @@ use std::sync::Arc;\n use tokio::sync::RwLock;\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n journal::JournalClient,\n@@ -43,6 +44,42 @@ fn parent_from_event_scope(scope: &str) -> ItemId {\n }\n }\n \n+fn apply_event(\n+ ev: Event,\n+ tree: &mut GlobalTree,\n+ entity_store: &EntityStore,\n+) -> Result<(), crate::event_log::EventLogError> {\n+ match ev {\n+ Event::VoteRecorded {\n+ ts,\n+ a,\n+ b,\n+ ratio_left,\n+ ratio_right,\n+ scope,\n+ } => {\n+ if let Some(vote) = VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right) {\n+ let parent = parent_from_event_scope(&scope);\n+ tree.apply_vote(&parent, vote);\n+ }\n+ }\n+ Event::ViewRecorded { .. } => {}\n+ Event::NodeEnsured { id } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ tree.ensure_path(&parsed);\n+ }\n+ }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ if let Err(e) = apply_entity_import(tree, entity_store, &parsed, payload) {\n+ tracing::warn!(item = %id, err = %e, \"entity replay failed\");\n+ }\n+ }\n+ }\n+ }\n+ Ok(())\n+}\n+\n #[derive(Clone)]\n pub struct AppConfig {\n pub data_dir: String,\n@@ -71,6 +108,7 @@ impl AppConfig {\n pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n+ pub entity_store: EntityStore,\n pub views: ViewStore,\n pub tree: Arc>,\n journal: JournalClient,\n@@ -82,48 +120,31 @@ impl AppState {\n let event_log = Arc::new(EventLog::new(cfg.event_log_path.clone()));\n let views_path = format!(\"{}/views.json\", cfg.data_dir);\n let views = ViewStore::new(&views_path);\n+ let entity_db_path = format!(\"{}/entity_db\", cfg.data_dir);\n+ let entity_store =\n+ EntityStore::open(std::path::Path::new(&entity_db_path)).expect(\"entity store\");\n \n let mut tree = GlobalTree::new();\n- if let Ok((events, _)) = event_log.load_all().await {\n- for ev in events {\n- match ev {\n- Event::VoteRecorded {\n- ts,\n- a,\n- b,\n- ratio_left,\n- ratio_right,\n- scope,\n- } => {\n- if let Some(vote) =\n- VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right)\n- {\n- let parent = parent_from_event_scope(&scope);\n- tree.apply_vote(&parent, vote);\n- }\n- }\n- Event::ViewRecorded { .. } => {}\n- Event::NodeEnsured { id } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- tree.ensure_path(&parsed);\n- }\n- }\n- Event::EntityImported { id, payload, .. } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n- }\n- }\n+ if let Err(e) = event_log\n+ .replay(|ev| apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ {\n+ tracing::warn!(err = %e, \"event log replay failed\");\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n+ let reddit = RedditBroker::spawn(\n+ tree.clone(),\n+ event_log.clone(),\n+ entity_store.clone(),\n+ RedditApiConfig::from_env(),\n+ );\n \n Self {\n cfg: Arc::new(cfg),\n event_log,\n+ entity_store,\n views,\n tree,\n journal,\n@@ -184,10 +205,10 @@ impl AppState {\n mod tests {\n use super::{normalize_scope, parse_item_param};\n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n path_types::ItemId,\n- reddit::apply_entity_import,\n reducer::GlobalTree,\n };\n use serde_json::json;\n@@ -197,6 +218,7 @@ mod tests {\n let tmp = tempfile::tempdir().unwrap();\n let log_path = tmp.path().join(\"events.jsonl\");\n let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let entity_store = EntityStore::open(&tmp.path().join(\"entity_db\")).unwrap();\n let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n log.append(&Event::EntityImported {\n id: \"reddit.com/r/rust\".into(),\n@@ -207,19 +229,16 @@ mod tests {\n .unwrap();\n \n let mut tree = GlobalTree::new();\n- let (events, _) = log.load_all().await.unwrap();\n- for ev in events {\n- if let Event::EntityImported { id, payload, .. } = ev {\n- let parsed = ItemId::parse(&id).unwrap();\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n+ log.replay(|ev| super::apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ .unwrap();\n let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n- assert_eq!(\n- node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n- \"rust\"\n- );\n+ let stored = entity_store\n+ .get(&ItemId::parse(\"reddit.com/r/rust\").unwrap())\n+ .unwrap()\n+ .unwrap();\n+ assert_eq!(stored[\"data\"][\"display_name\"], \"rust\");\n }\n \n #[test]\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 56d2db313b313ffce07eff38a8ccdaa2fbb9498f..32da054b58b97a4751a038c677d213d6c3fa7cdc 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -104,9 +104,17 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n let log = std::fs::read_to_string(tmp.path().join(\"events.jsonl\")).unwrap();\n assert!(log.contains(\"vote_recorded\"));\n \n+ // Replay in a fresh data dir (RocksDB locks entity_db while the server runs).\n+ let replay_tmp = TempDir::new().unwrap();\n+ std::fs::copy(\n+ tmp.path().join(\"events.jsonl\"),\n+ replay_tmp.path().join(\"events.jsonl\"),\n+ )\n+ .unwrap();\n+ let replay_data = replay_tmp.path().to_string_lossy().into_owned();\n let cfg = AppConfig {\n- data_dir: tmp.path().to_string_lossy().into_owned(),\n- event_log_path: tmp.path().join(\"events.jsonl\").to_string_lossy().into_owned(),\n+ data_dir: replay_data.clone(),\n+ event_log_path: format!(\"{replay_data}/events.jsonl\"),\n port: 0,\n };\n let state = create_app_state(cfg).await;\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}