You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [c99adc26] refactor forum into files Side A — unified diff (full patch): diff --git a/bb.edn b/bb.edn index 4680e82d4227057d2eea6041dc5647beb759131d..ce0f6fcd45a67ead3295b90db2a301e9b304f3da 100644 --- a/bb.edn +++ b/bb.edn @@ -51,6 +51,11 @@ :requires ([test.walkthrough-fixture :as walkthrough-fixture]) :task (walkthrough-fixture/run-fixture)} + sample-fixture + {:doc "macOS: run `sample` on process(es) listening on the fixture TCP port (default 8080). Usage: bb sample-fixture [PORT] [DURATION_SEC] [OUT_DIR]" + :requires ([scripts.sample-fixture :as sample-fixture]) + :task (apply sample-fixture/-main *command-line-args*)} + perf {:doc "Performance test: concurrent HTTP requests to detect blocking I/O" :requires ([scripts.perf :as perf]) diff --git a/scripts/sample_fixture.bb b/scripts/sample_fixture.bb new file mode 100644 index 0000000000000000000000000000000000000000..ac2368623c5043ba6e027afef6b5b37d392767b0 --- /dev/null +++ b/scripts/sample_fixture.bb @@ -0,0 +1,70 @@ +(ns scripts.sample-fixture + "Find process(es) listening on the fixture port (default 8080) and run macOS `sample`." + (:require [babashka.fs :as fs] + [babashka.process :as p] + [clojure.string :as str])) + +(defn- usage [] + (println "Usage: bb sample-fixture [PORT] [DURATION_SEC] [OUT_DIR]") + (println "") + (println " Finds PIDs bound to TCP LISTEN on PORT (default 8080), then runs") + (println " `sample` for each PID. OUT_DIR defaults to the current directory.") + (println "") + (println " Example: bb sample-fixture") + (println " bb sample-fixture 8080 10") + (println " bb sample-fixture 8080 5 /tmp") + (println "") + (println " Requires macOS (the `sample` tool).")) + +(defn- parse-long* [s] + (try (Long/parseLong s) + (catch NumberFormatException _ nil))) + +(defn- listen-pids [port] + (let [spec (str "TCP:" port) + {:keys [out exit]} + @(p/process ["lsof" "-nP" (str "-i" spec) "-sTCP:LISTEN" "-t"] + {:out :string :err :string})] + (when (zero? exit) + (->> (str/split-lines out) + (map str/trim) + (remove str/blank?) + (distinct) + vec)))) + +(defn- sample-bin [] + (or (fs/which "sample") + (throw (ex-info "macOS `sample` not found on PATH" {})))) + +(defn- run-sample! [sample duration-sec pid out-file] + (println (str "sampling PID " pid " for " duration-sec "s → " out-file)) + (let [{:keys [exit err]} @(p/process [sample (str pid) (str duration-sec) "-file" out-file] + {:out :inherit :err :inherit})] + (when-not (zero? exit) + (binding [*out* *err*] + (println "sample failed:" err)) + (System/exit exit)))) + +(defn -main [& args] + (when (some #{"-h" "--help" "help"} args) + (usage) + (System/exit 0)) + (let [port (or (some-> (first args) parse-long*) 8080) + duration-sec (or (some-> (second args) parse-long*) 5) + out-dir (or (nth args 2 nil) ".") + pids (listen-pids port)] + (when (or (nil? pids) (empty? pids)) + (binding [*out* *err*] + (println (str "No process listening on TCP " port " (LISTEN). Is `bb fixture` running?"))) + (System/exit 1)) + (when-not (fs/exists? out-dir) + (binding [*out* *err*] + (println "Output directory does not exist:" out-dir)) + (System/exit 1)) + (let [sample (sample-bin) + ts (str (System/currentTimeMillis))] + (println (str "port " port " → PIDs " (str/join ", " pids))) + (doseq [pid pids] + (let [out-file (str (fs/path out-dir) "/slug-sample-" port "-" pid "-" ts ".txt")] + (run-sample! sample duration-sec pid (str out-file)))) + (println "done.")))) diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs index a55fc1f79d03719eb74b216865b2c15199c48ea7..5ad8dfc84dd735d589432e2c613ff687a75f2e61 100644 --- a/server/src/html/forum.rs +++ b/server/src/html/forum.rs @@ -380,22 +380,6 @@ fn room_members_inner(members: &[RoomMemberRow]) -> Markup { } } -pub(crate) fn set_room_members_expanded_rpc(room_wire: &str, expanded: bool) -> String { - template_json_compact(&HtmlUiAction::SetRoomMembersExpanded { - room_wire: room_wire.to_string(), - expanded, - }) - .expect("static json") -} - -pub(crate) fn set_room_new_thread_compose_expanded_rpc(nav: &ThreadNav, expanded: bool) -> String { - template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { - room_wire: nav.room_wire.clone(), - expanded, - }) - .expect("static json") -} - /// Fragment for `#room-members-section` — expand/collapse is server-driven via `POST /ui`. pub(crate) fn room_members_section_markup( reduced: &ReducerState, @@ -406,13 +390,14 @@ pub(crate) fn room_members_section_markup( if members.is_empty() { return html! {}; } - let rpc_open = set_room_members_expanded_rpc(room_id, true); - let rpc_close = set_room_members_expanded_rpc(room_id, false); html! { div id="room-members-section" { @if members_expanded { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_close); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded { + room_wire: room_id.to_string(), + expanded: false, + }).expect("static json")); button type="submit" class="form-toggle" aria-expanded="true" { "hide members & permissions" } @@ -422,7 +407,10 @@ pub(crate) fn room_members_section_markup( } } @else { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_open); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded { + room_wire: room_id.to_string(), + expanded: true, + }).expect("static json")); button type="submit" class="form-toggle" aria-expanded="false" { "members & permissions" } @@ -514,28 +502,24 @@ fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup { if !show { return html! {}; } - let rpc_post = template_json_compact(&json!({ - "action": "post_ingest", - "room": nav.room_wire, - "thread_tag": thread_tag, - "text": {"$form": "text"}, - "error_target": "thread-compose-errors", - "form_id": "thread-compose-form", - })) - .unwrap(); - let rpc_check = template_json_compact(&json!({ - "action": "check_ingest", - "room": nav.room_wire, - "thread_tag": thread_tag, - "text": {"$form": "text"}, - "error_target": "thread-compose-errors", - "form_id": "thread-compose-form", - })) - .unwrap(); html! { section class="compose" id="thread-compose" { - form id="thread-compose-form" method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(rpc_check) { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_post); + form id="thread-compose-form" method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({ + "action": "check_ingest", + "room": nav.room_wire, + "thread_tag": thread_tag, + "text": {"$form": "text"}, + "error_target": "thread-compose-errors", + "form_id": "thread-compose-form", + })).unwrap()) { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ + "action": "post_ingest", + "room": nav.room_wire, + "thread_tag": thread_tag, + "text": {"$form": "text"}, + "error_target": "thread-compose-errors", + "form_id": "thread-compose-form", + })).unwrap()); textarea name="text" rows="5" cols="80" placeholder="prose or ~/items and votes…" {} p { button type="submit" { "post" } @@ -712,7 +696,7 @@ pub async fn home( p class="muted" { "dark = time-ordered · light = vote-ranked" } div class="thread-feed-toolbar" { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(expand_public_new_thread_rpc_value()); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json")); button type="submit" class="section-add-btn" { "+" } } } @@ -1002,14 +986,15 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) if !show { return html! {}; } - let rpc_open = set_room_new_thread_compose_expanded_rpc(nav, true); - let rpc_close = set_room_new_thread_compose_expanded_rpc(nav, false); // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section). html! { div class="room-new-thread-slot-inner" { @if compose_expanded { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_close); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + room_wire: nav.room_wire.clone(), + expanded: false, + }).expect("static json")); button type="submit" class="form-toggle" aria-expanded="true" { "-" } @@ -1039,7 +1024,10 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) } } @else { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_open); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + room_wire: nav.room_wire.clone(), + expanded: true, + }).expect("static json")); button type="submit" class="form-toggle" aria-expanded="false" { "+" } @@ -1049,10 +1037,6 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) } } -pub(crate) fn expand_public_new_thread_rpc_value() -> String { - template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json") -} - pub(crate) fn login_to_post_hint_markup() -> Markup { html! { p class="muted" { "log in to post" } diff --git a/server/src/html/forum/access.rs b/server/src/html/forum/access.rs new file mode 100644 index 0000000000000000000000000000000000000000..f9f7139b63442a6ef3a927b77a72805f15feec3f --- /dev/null +++ b/server/src/html/forum/access.rs @@ -0,0 +1,16 @@ +use crate::events::ThreadCapability; +use crate::reducer::ReducerState; + +pub(crate) fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool { + if !reduced.rooms.contains(room_id) { + return false; + } + let Some(u) = username else { + return false; + }; + reduced.user_has_cap(room_id, u, ThreadCapability::View) +} + +pub(crate) fn user_can_post_room(reduced: &ReducerState, room_id: &str, username: &str) -> bool { + reduced.user_has_cap(room_id, username, ThreadCapability::Post) +} diff --git a/server/src/html/forum/ingest.rs b/server/src/html/forum/ingest.rs new file mode 100644 index 0000000000000000000000000000000000000000..c045046a005db4f8f4d1dc7ea74aaaba45e0f3f4 --- /dev/null +++ b/server/src/html/forum/ingest.rs @@ -0,0 +1,171 @@ +use crate::canonical_path::canonicalize_tag; +use crate::form_template::template_json_compact; +use crate::reducer::{scope_from_room_wire, ReducerState}; +use maud::{html, Markup}; +use serde_json::json; + +use crate::html::js_string_literal; +use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD}; +use crate::html::{profile_href, render_linkified_with_embeds_in_scope, timeago}; + +use super::nav::ThreadNav; + +/// `POST /ui` + `__rpc__` from an inline link (`onclick`); same-origin credentials as other morph actions. +pub(super) fn thread_ui_fetch_onclick(rpc_compact_json: &str) -> String { + format!( + "fetch('/ui',{{method:'POST',headers:{{'Content-Type':'application/x-www-form-urlencoded'}},body:new URLSearchParams({{__rpc__:{}}}).toString(),credentials:'same-origin'}}).then(r=>r.text()).then(eval);return false", + js_string_literal(rpc_compact_json) + ) +} + +pub(super) fn thread_nav_for_ingest(ing: &crate::events::Ingest) -> Option { + let room = ing.room_id.trim(); + if room.is_empty() || room == "public" { + Some(ThreadNav::public()) + } else { + ThreadNav::from_room_id(room) + } +} + +pub(super) fn thread_post_index_in_scope(reduced: &ReducerState, ing: &crate::events::Ingest) -> Option { + let scope = scope_from_room_wire(&ing.room_id); + let tag = canonicalize_tag(&ing.thread_tag); + reduced + .ingests_by_scope_thread + .get(&(scope, tag)) + .and_then(|q| q.iter().rev().position(|id| id == &ing.id)) +} + +fn post_header_meta( + nav: &ThreadNav, + tag: &str, + post_idx: usize, + principal: &str, + ts: i64, + now: i64, +) -> Markup { + let post_href = nav.post_url(tag, post_idx); + let profile = profile_href(principal); + let hover = timeago::rfc3339_utc(ts); + let ago = timeago::timeago(now, ts); + html! { + div class="ingest-meta muted" title=(hover) { + a href=(post_href) class="post-num" { "#" (post_idx) } + " " + a href=(profile) class="post-author" { "@" (principal) } + " · " + (ago) + } + } +} + +pub(super) fn post_header_row( + nav: &ThreadNav, + tag: &str, + post_idx: usize, + ing: &crate::events::Ingest, + _viewer: Option<&str>, + now: i64, + show_delete: bool, +) -> Markup { + let meta = post_header_meta(nav, tag, post_idx, &ing.principal, ing.ts, now); + html! { + div class="ingest-header-row" { + (meta) + @if show_delete { + form class="post-delete-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::RedactPost { post_id: ing.id.clone() }).unwrap()); + button type="submit" class="post-delete-btn" { "delete" } + } + } + } + } +} + +pub(super) fn redacted_header_row( + nav: &ThreadNav, + tag: &str, + post_idx: usize, + ing: &crate::events::Ingest, + now: i64, + expanded: bool, +) -> Markup { + let meta = post_header_meta(nav, tag, post_idx, &ing.principal, ing.ts, now); + let rpc_expand = template_json_compact(&json!({ + "action": "expand_redacted_post", + "room": nav.room_wire, + "thread_tag": tag, + "post_index": post_idx, + })) + .unwrap(); + let rpc_collapse = template_json_compact(&json!({ + "action": "collapse_redacted_post", + "room": nav.room_wire, + "thread_tag": tag, + "post_index": post_idx, + })) + .unwrap(); + let onclick_expand = thread_ui_fetch_onclick(&rpc_expand); + let onclick_collapse = thread_ui_fetch_onclick(&rpc_collapse); + html! { + div class="ingest-header-row ingest-tombstone-row" { + (meta) + span class="post-tombstone-inline muted" { + "deleted · " + @if expanded { + a href="#" class="hide-deleted-link" + onclick=(onclick_collapse) { + "[hide deleted content]" + } + } @else { + a href="#" class="show-deleted-link" + onclick=(onclick_expand) { + "[show deleted content]" + } + } + } + } + } +} + +pub(super) fn ingest_entry_markup( + nav: &ThreadNav, + tag: &str, + post_idx: usize, + ing: &crate::events::Ingest, + viewer: Option<&str>, + now: i64, + reduced: &ReducerState, +) -> Markup { + let redacted = reduced.redacted_posts.contains(&ing.id); + let show_delete = viewer == Some(ing.principal.as_str()) && !redacted; + if redacted { + html! { + div class="ingest-entry ingest-redacted" data-ingest-id=(ing.id) { + (redacted_header_row(nav, tag, post_idx, ing, now, false)) + } + } + } else { + let truncated = ing.raw.len() > 2000; + let display_body = if truncated { &ing.raw[..2000] } else { &ing.raw[..] }; + html! { + div class="ingest-entry" data-ingest-id=(ing.id) { + (post_header_row(nav, tag, post_idx, ing, viewer, now, show_delete)) + (render_linkified_with_embeds_in_scope(display_body, nav.garden_root_url())) + @if truncated { + @let rpc_full = template_json_compact(&json!({ + "action": "expand_post_full", + "room": nav.room_wire, + "thread_tag": tag, + "post_index": post_idx, + })).unwrap(); + @let onclick_full = thread_ui_fetch_onclick(&rpc_full); + a href="#" class="show-full-link" + onclick=(onclick_full) { + "[show full post]" + } + } + } + } + } +} diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5d437f2b1c6a0b727f1540d83774ba4e440f0b1 --- /dev/null +++ b/server/src/html/forum/nav.rs @@ -0,0 +1,78 @@ +use crate::canonical_path::canonicalize_item; +use crate::reducer::ScopeId; + +/// URL helpers for public `/t/…` and private room threads `/r/{short}/{slug}/t/…`. +#[derive(Clone)] +pub struct ThreadNav { + pub room_wire: String, + scope: ScopeId, + room_path: String, + thread_path_prefix: String, + garden_path_prefix: String, +} + +impl ThreadNav { + pub(crate) fn public() -> Self { + Self { + room_wire: "public".into(), + scope: ScopeId::Public, + room_path: "/t".into(), + thread_path_prefix: "/t".into(), + garden_path_prefix: "/~".into(), + } + } + + /// `room_id` wire form `shortid/slug`. + pub(crate) fn from_room_id(room_id: &str) -> Option { + let (short, slug) = room_id.split_once('/')?; + if short.is_empty() || slug.is_empty() { + return None; + } + Some(Self { + room_wire: room_id.to_string(), + scope: ScopeId::Room(room_id.to_string()), + room_path: format!("/r/{short}/{slug}"), + thread_path_prefix: format!("/r/{short}/{slug}/t"), + garden_path_prefix: format!("/r/{short}/{slug}/~"), + }) + } + + pub(crate) fn scope(&self) -> ScopeId { + self.scope.clone() + } + + pub(crate) fn room_url(&self) -> &str { + &self.room_path + } + + pub(crate) fn thread_url(&self, tag: &str) -> String { + format!("{}/{}", self.thread_path_prefix, tag) + } + + pub(crate) fn garden_root_url(&self) -> &str { + &self.garden_path_prefix + } + + pub(crate) fn garden_item_url(&self, item: &str) -> String { + if let Some(tail) = crate::path_types::CanonicalItemUrl::parse(item) + .and_then(|c| c.tilde_tail().map(str::to_owned)) + { + format!("{}/{}", self.garden_path_prefix, tail) + } else { + format!("{}/{}", self.garden_path_prefix, canonicalize_item(item)) + } + } + + pub(crate) fn thread_page_url(&self, tag: &str, offset: usize) -> String { + let base = self.thread_url(tag); + if offset == 0 { + base + } else { + format!("{base}?offset={offset}") + } + } + + pub(crate) fn post_url(&self, tag: &str, idx: usize) -> String { + format!("{}/{}/{}", self.thread_path_prefix, tag, idx) + } +} diff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs new file mode 100644 index 0000000000000000000000000000000000000000..b2d24b8ab3ec7eee0edd16bed8ee6605e49bf408 --- /dev/null +++ b/server/src/html/forum/new_thread.rs @@ -0,0 +1,157 @@ +use crate::form_template::template_json_compact; +use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD}; +use maud::{html, Markup}; +use serde_json::json; + +use super::nav::ThreadNav; + +struct NewThreadIds { + compose_section_id: &'static str, + errors_id: &'static str, + form_id: &'static str, + tag_input_id: &'static str, + text_input_id: Option<&'static str>, +} + +const PUBLIC_IDS: NewThreadIds = NewThreadIds { + compose_section_id: "public-new-thread-compose", + errors_id: "public-new-thread-errors", + form_id: "public-new-thread-form", + tag_input_id: "new-thread-tag", + text_input_id: Some("new-thread-text"), +}; + +const ROOM_IDS: NewThreadIds = NewThreadIds { + compose_section_id: "room-new-thread-compose", + errors_id: "room-new-thread-errors", + form_id: "room-new-thread-form", + tag_input_id: "room-new-tag", + text_input_id: None, +}; + +#[derive(Clone, Copy)] +enum NewThreadComposeKind { + /// Home page: no client-side check RPC; post template omits `error_target` / `form_id`. + Public, + /// Room page: `check_ingest` + error targets on post (matches thread compose). + Room, +} + +/// Shared `
` for creating a thread + first post. +fn new_thread_compose_section(room_wire: &str, ids: &NewThreadIds, kind: NewThreadComposeKind) -> Markup { + let client_check = matches!(kind, NewThreadComposeKind::Room); + let (tag_placeholder, text_placeholder, submit_label) = match kind { + NewThreadComposeKind::Public => ( + "thread-title-slug-here", + "Hello threadgoers!! Behold my new thread!", + "create thread / make first post", + ), + NewThreadComposeKind::Room => ( + "thread-topic-slug-here", + "First post body…", + "post", + ), + }; + + html! { + section class="compose" id=(ids.compose_section_id) { + div id=(ids.errors_id) {} + @if client_check { + form id=(ids.form_id) method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({ + "action": "check_ingest", + "room": room_wire, + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + "error_target": ids.errors_id, + "form_id": ids.form_id, + })).unwrap()) { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ + "action": "post_ingest", + "room": room_wire, + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + "error_target": ids.errors_id, + "form_id": ids.form_id, + })).unwrap()); + input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" required placeholder=(tag_placeholder); + @if let Some(tid) = ids.text_input_id { + textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) required {} + } @else { + textarea name="text" rows="4" placeholder=(text_placeholder) required {} + } + p { button type="submit" { (submit_label) } } + } + } @else { + form id=(ids.form_id) method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ + "action": "post_ingest", + "room": room_wire, + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + })).unwrap()); + input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" placeholder=(tag_placeholder); + @if let Some(tid) = ids.text_input_id { + textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) {} + } @else { + textarea name="text" rows="4" placeholder=(text_placeholder) {} + } + p { button type="submit" { (submit_label) } } + } + } + } + } +} + +fn new_thread_form_public(show: bool) -> Markup { + if !show { + return html! {}; + } + new_thread_compose_section("public", &PUBLIC_IDS, NewThreadComposeKind::Public) +} + +fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + if !show { + return html! {}; + } + // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section). + html! { + div class="room-new-thread-slot-inner" { + @if compose_expanded { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + room_wire: nav.room_wire.clone(), + expanded: false, + }).expect("static json")); + button type="submit" class="form-toggle" aria-expanded="true" { + "-" + } + } + (new_thread_compose_section(&nav.room_wire, &ROOM_IDS, NewThreadComposeKind::Room)) + } @else { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + room_wire: nav.room_wire.clone(), + expanded: true, + }).expect("static json")); + button type="submit" class="form-toggle" aria-expanded="false" { + "+" + } + } + } + } + } +} + +pub(crate) fn login_to_post_hint_markup() -> Markup { + html! { + p class="muted" { "log in to post" } + } +} + +pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup { + new_thread_form_public(show) +} + +pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + new_thread_form_for_room(nav, show, compose_expanded) +} diff --git a/server/src/html/forum/room_members.rs b/server/src/html/forum/room_members.rs new file mode 100644 index 0000000000000000000000000000000000000000..bc13d3eb8030f00799c90c47e689a876168f522c --- /dev/null +++ b/server/src/html/forum/room_members.rs @@ -0,0 +1,108 @@ +use crate::events::ThreadCapability; +use crate::form_template::template_json_compact; +use crate::reducer::ReducerState; +use maud::{html, Markup}; + +use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD}; + +#[derive(Clone)] +pub(super) struct RoomMemberRow { + pub(super) username: String, + pub(super) capabilities: Vec<&'static str>, +} + +fn capability_label(cap: ThreadCapability) -> &'static str { + match cap { + ThreadCapability::View => "view", + ThreadCapability::Post => "post", + ThreadCapability::Vote => "vote", + ThreadCapability::AddItem => "add_item", + ThreadCapability::Manage => "manage", + } +} + +fn room_members_for_room(reduced: &ReducerState, room_id: &str) -> Vec { + let mut rows: Vec = reduced + .grants + .get(room_id) + .into_iter() + .flat_map(|members| members.iter()) + .map(|(username, caps)| { + let mut ordered = Vec::new(); + for cap in [ + ThreadCapability::View, + ThreadCapability::Post, + ThreadCapability::Vote, + ThreadCapability::AddItem, + ThreadCapability::Manage, + ] { + if caps.contains(&cap) { + ordered.push(capability_label(cap)); + } + } + RoomMemberRow { + username: username.clone(), + capabilities: ordered, + } + }) + .collect(); + rows.sort_by(|a, b| a.username.cmp(&b.username)); + rows +} + +fn room_members_inner(members: &[RoomMemberRow]) -> Markup { + html! { + h3 { "members" } + ul class="room-members" { + @for member in members { + li { + span class="room-member-name" { "@" (member.username) } + span class="muted" { + " · " + (member.capabilities.join(", ")) + } + } + } + } + } +} + +/// Fragment for `#room-members-section` — expand/collapse is server-driven via `POST /ui`. +pub(crate) fn room_members_section_markup( + reduced: &ReducerState, + room_id: &str, + members_expanded: bool, +) -> Markup { + let members = room_members_for_room(reduced, room_id); + if members.is_empty() { + return html! {}; + } + html! { + div id="room-members-section" { + @if members_expanded { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded { + room_wire: room_id.to_string(), + expanded: false, + }).expect("static json")); + button type="submit" class="form-toggle" aria-expanded="true" { + "hide members & permissions" + } + } + section class="room-members-panel" { + (room_members_inner(&members)) + } + } @else { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded { + room_wire: room_id.to_string(), + expanded: true, + }).expect("static json")); + button type="submit" class="form-toggle" aria-expanded="false" { + "members & permissions" + } + } + } + } + } +} Side B — contributor: tommy-mor Side B — commit message: [2a94401b] Run rustfmt workspace-wide and fix lint tooling. Pin rustfmt and clippy in rust-toolchain.toml after a broken component install, merge a duplicate vote-slider CSS rule, and add VS Code settings so rust-analyzer uses the project toolchain. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..98ebd754a6d5a972550506c69c5a10c10e3210b6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "rust-analyzer.rustc.source": "discover", + "rust-analyzer.check.command": "check", + "rust-analyzer.procMacro.enable": true, + "rust-analyzer.cargo.extraEnv": { + "RUSTUP_TOOLCHAIN": "1.88.0" + } +} diff --git a/durable/examples/combined_example.rs b/durable/examples/combined_example.rs index 626a6e1cf7e3c9c26f9f2edc58950d9bc31ec67e..6e9cb3210714d74c9a2cc0ed4f87e1b8d84788da 100644 --- a/durable/examples/combined_example.rs +++ b/durable/examples/combined_example.rs @@ -1,5 +1,5 @@ use durable::{Db, DurableMap, DurableVec}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,36 +28,48 @@ fn get_timestamp() -> u64 { fn main() -> Result<(), Box> { // Open or create a database let db = Db::open("chat_db")?; - + // Create our collections let mut users = DurableMap::::new(&db, "users")?; let mut messages = DurableVec::::new(&db, "messages")?; let mut user_message_indices = DurableMap::>::new(&db, "user_messages")?; - + // Create some users - users.insert("alice".to_string(), User { - username: "alice".to_string(), - display_name: "Alice Smith".to_string(), - message_count: 0, - })?; - - users.insert("bob".to_string(), User { - username: "bob".to_string(), - display_name: "Bob Johnson".to_string(), - message_count: 0, - })?; - - users.insert("charlie".to_string(), User { - username: "charlie".to_string(), - display_name: "Charlie Brown".to_string(), - message_count: 0, - })?; - + users.insert( + "alice".to_string(), + User { + username: "alice".to_string(), + display_name: "Alice Smith".to_string(), + message_count: 0, + }, + )?; + + users.insert( + "bob".to_string(), + User { + username: "bob".to_string(), + display_name: "Bob Johnson".to_string(), + message_count: 0, + }, + )?; + + users.insert( + "charlie".to_string(), + User { + username: "charlie".to_string(), + display_name: "Charlie Brown".to_string(), + message_count: 0, + }, + )?; + // Helper to send a message - let send_message = |from: &str, to: &str, content: &str, + let send_message = |from: &str, + to: &str, + content: &str, messages: &mut DurableVec, users: &mut DurableMap, - indices: &mut DurableMap>| -> Result<(), Box> { + indices: &mut DurableMap>| + -> Result<(), Box> { // Create message let msg_id = messages.len()? as u64; let message = Message { @@ -67,61 +79,93 @@ fn main() -> Result<(), Box> { content: content.to_string(), timestamp: get_timestamp(), }; - + // Store message messages.push(message)?; let msg_index = messages.len()? - 1; - + // Update sender's message count if let Some(mut sender) = users.get(&from.to_string())? { sender.message_count += 1; users.insert(from.to_string(), sender)?; } - + // Track message indices for recipient let mut recipient_indices = indices.get(&to.to_string())?.unwrap_or_default(); recipient_indices.push(msg_index); indices.insert(to.to_string(), recipient_indices)?; - + Ok(()) }; - + // Send some messages println!("💬 Chat Application Demo\n"); println!("Sending messages..."); - - send_message("alice", "bob", "Hey Bob, how's the Durable library coming along?", - &mut messages, &mut users, &mut user_message_indices)?; - - send_message("bob", "alice", "It's going great! We have DurableVec and DurableMap working!", - &mut messages, &mut users, &mut user_message_indices)?; - - send_message("charlie", "alice", "That sounds awesome! Can I help with testing?", - &mut messages, &mut users, &mut user_message_indices)?; - - send_message("alice", "charlie", "Absolutely! The more testing the better!", - &mut messages, &mut users, &mut user_message_indices)?; - - send_message("bob", "charlie", "Check out the examples directory for usage patterns", - &mut messages, &mut users, &mut user_message_indices)?; - + + send_message( + "alice", + "bob", + "Hey Bob, how's the Durable library coming along?", + &mut messages, + &mut users, + &mut user_message_indices, + )?; + + send_message( + "bob", + "alice", + "It's going great! We have DurableVec and DurableMap working!", + &mut messages, + &mut users, + &mut user_message_indices, + )?; + + send_message( + "charlie", + "alice", + "That sounds awesome! Can I help with testing?", + &mut messages, + &mut users, + &mut user_message_indices, + )?; + + send_message( + "alice", + "charlie", + "Absolutely! The more testing the better!", + &mut messages, + &mut users, + &mut user_message_indices, + )?; + + send_message( + "bob", + "charlie", + "Check out the examples directory for usage patterns", + &mut messages, + &mut users, + &mut user_message_indices, + )?; + // Display all users and their message counts println!("\n👥 Users:"); let mut all_users = users.to_vec()?; all_users.sort_by_key(|(username, _)| username.clone()); - + for (username, user) in all_users { - println!(" {} ({}) - {} messages sent", - user.display_name, username, user.message_count); + println!( + " {} ({}) - {} messages sent", + user.display_name, username, user.message_count + ); } - + // Display all messages println!("\n📨 All messages:"); for (i, msg) in messages.iter()?.enumerate() { let msg = msg?; println!(" [{}] {} → {}: {}", i, msg.from, msg.to, msg.content); } - + // Show inbox for each user println!("\n📥 User inboxes:"); for item in users.iter() { @@ -135,26 +179,26 @@ fn main() -> Result<(), Box> { } } } - + // Statistics println!("\n📊 Statistics:"); println!(" Total users: {}", users.len()?); println!(" Total messages: {}", messages.len()?); - + // Demonstrate persistence println!("\n💾 Data has been persisted to disk!"); println!(" Database location: ./chat_db"); - + // Clean up drop(messages); drop(users); drop(user_message_indices); drop(db); - + // Remove the database for this example std::fs::remove_dir_all("chat_db").ok(); - + println!("\n✅ Example completed!"); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/map_example.rs b/durable/examples/map_example.rs index 08b8f2c8826caf53c4c20b422a540c92f6624029..1d9c4a14b0f4cfe39ade84ebb1f1a14033e8ff1b 100644 --- a/durable/examples/map_example.rs +++ b/durable/examples/map_example.rs @@ -1,5 +1,5 @@ use durable::{Db, DurableMap}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] struct UserProfile { @@ -11,10 +11,10 @@ struct UserProfile { fn main() -> Result<(), Box> { // Open or create a database let db = Db::open("example_db")?; - + // Create a persistent map of user profiles let mut users = DurableMap::::new(&db, "users")?; - + // Insert some users // Using put() when we don't need the old value - more efficient! users.put( @@ -25,7 +25,7 @@ fn main() -> Result<(), Box> { score: 1500, }, )?; - + users.put( "bob".to_string(), UserProfile { @@ -34,7 +34,7 @@ fn main() -> Result<(), Box> { score: 1200, }, )?; - + // Using insert() when we might need the old value let old_charlie = users.insert( "charlie".to_string(), @@ -44,21 +44,24 @@ fn main() -> Result<(), Box> { score: 1800, }, )?; - + if old_charlie.is_some() { println!("Replaced existing charlie entry"); } - + println!("Total users: {}", users.len()?); - + // Look up a specific user if let Some(alice) = users.get(&"alice".to_string())? { println!("\nAlice's profile: {:?}", alice); } - + // Check if a user exists - println!("\nDoes 'david' exist? {}", users.contains_key(&"david".to_string())?); - + println!( + "\nDoes 'david' exist? {}", + users.contains_key(&"david".to_string())? + ); + // Update a user's score if let Some(mut bob) = users.get(&"bob".to_string())? { bob.score += 100; @@ -66,34 +69,40 @@ fn main() -> Result<(), Box> { users.put("bob".to_string(), bob)?; println!("Updated Bob's score!"); } - + // Iterate over all users println!("\nAll users (sorted by username):"); let mut all_users = users.to_vec()?; all_users.sort_by_key(|(username, _)| username.clone()); - + for (username, profile) in all_users { - println!(" {} ({}) - Score: {}", username, profile.email, profile.score); + println!( + " {} ({}) - Score: {}", + username, profile.email, profile.score + ); } - + // Get just the usernames let mut usernames = users.keys_vec()?; usernames.sort(); println!("\nAll usernames: {:?}", usernames); - + // Find the highest scoring user let profiles = users.values_vec()?; if let Some(top_user) = profiles.iter().max_by_key(|p| p.score) { - println!("\nTop scorer: {} with {} points", top_user.name, top_user.score); + println!( + "\nTop scorer: {} with {} points", + top_user.name, top_user.score + ); } - + // Remove a user if let Some(removed) = users.remove(&"charlie".to_string())? { println!("\nRemoved user: {}", removed.name); println!("Users remaining: {}", users.len()?); } - + println!("\nData has been persisted to disk."); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/nested_example.rs b/durable/examples/nested_example.rs index 3b880f2c8b8ad2633d4b5fcf2016652216c9de8e..f16090cf6fb854ac7a1b00acfd31fbd12c25dbce 100644 --- a/durable/examples/nested_example.rs +++ b/durable/examples/nested_example.rs @@ -3,27 +3,31 @@ use durable::{Db, DurableMap, DurableVec}; fn main() -> Result<(), Box> { // Open a database let db = Db::open("nested_example_db")?; - + // Create a map where each user has a list of posts - let user_posts: DurableMap> = DurableMap::new_nested(&db, "user_posts"); - + let user_posts: DurableMap> = + DurableMap::new_nested(&db, "user_posts"); + // Add posts for Alice println!("Adding posts for Alice..."); let mut alice_posts = user_posts.entry("alice".to_string())?.or_default()?; alice_posts.push("Hello, world!".to_string())?; alice_posts.push("Rust is awesome!".to_string())?; alice_posts.push("Loving persistent data structures!".to_string())?; - + // Add posts for Bob println!("Adding posts for Bob..."); let mut bob_posts = user_posts.entry("bob".to_string())?.or_default()?; bob_posts.push("First post".to_string())?; bob_posts.push("Learning Rust".to_string())?; - + // Add a post for Charlie in a chained call println!("Adding post for Charlie..."); - user_posts.entry("charlie".to_string())?.or_default()?.push("One-liner post!".to_string())?; - + user_posts + .entry("charlie".to_string())? + .or_default()? + .push("One-liner post!".to_string())?; + // Read back Alice's posts println!("\nAlice's posts:"); let alice_posts_read = user_posts.entry("alice".to_string())?.or_default()?; @@ -32,7 +36,7 @@ fn main() -> Result<(), Box> { println!(" {}: {}", i + 1, post); } } - + // Read back Bob's posts println!("\nBob's posts:"); let bob_posts_read = user_posts.entry("bob".to_string())?.or_default()?; @@ -41,7 +45,7 @@ fn main() -> Result<(), Box> { println!(" {}: {}", i + 1, post); } } - + // Read back Charlie's posts println!("\nCharlie's posts:"); let charlie_posts_read = user_posts.entry("charlie".to_string())?.or_default()?; @@ -50,15 +54,15 @@ fn main() -> Result<(), Box> { println!(" {}: {}", i + 1, post); } } - + println!("\nDemonstration of persistence..."); println!("Data is now persisted to disk. You can stop and restart this program,"); println!("and all the posts will still be there!"); - + println!("\nTotal users with posts: 3"); println!("Alice has {} posts", alice_posts_read.len()?); println!("Bob has {} posts", bob_posts_read.len()?); println!("Charlie has {} posts", charlie_posts_read.len()?); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/ranking_history.rs b/durable/examples/ranking_history.rs index 4da8592d240318318ae20c834d616210edb16c8d..7624f1780de18b04b239b8b05789aafdd07310cc 100644 --- a/durable/examples/ranking_history.rs +++ b/durable/examples/ranking_history.rs @@ -1,5 +1,5 @@ use durable::{Db, DurableMap, DurableVec}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] struct RankingEntry { @@ -24,9 +24,9 @@ impl RankingEntry { fn main() -> Result<(), Box> { println!("🎮 Gaming Ranking History System (Rewritten with Nested Entry API)"); println!("================================================================"); - + let db = Db::open("ranking_history_db")?; - + // THE CORE CHANGE: Define the truly nested data structure. // Instead of a composite key, we nest a Map within a Map. // This represents the ideal, ergonomic API. @@ -35,16 +35,16 @@ fn main() -> Result<(), Box> { type Rankings = DurableMap; let rankings: Rankings = DurableMap::new_nested(&db, "game_rankings_v2"); - + // Simulate some game days let today = 20241215u32; let yesterday = 20241214u32; let last_week = 20241208u32; - + // No more `make_key` helper function! - + println!("\n📊 Adding ranking data using chained entry().or_default()..."); - + // Add rankings for CS2 today. This demonstrates the new, clean access pattern. println!("Adding CS2 rankings for today ({})", today); let mut cs2_today = rankings @@ -57,7 +57,7 @@ fn main() -> Result<(), Box> { cs2_today.push(RankingEntry::new("player2", 2380))?; cs2_today.push(RankingEntry::new("player3", 2320))?; cs2_today.push(RankingEntry::new("player4", 2280))?; - + // Add rankings for CS2 yesterday println!("Adding CS2 rankings for yesterday ({})", yesterday); rankings @@ -90,7 +90,7 @@ fn main() -> Result<(), Box> { valorant_today.push(RankingEntry::new("player6", 1850))?; valorant_today.push(RankingEntry::new("player7", 1820))?; valorant_today.push(RankingEntry::new("player1", 1800))?; // Same player, different game - + // Add TF2 rankings (matching the docs example) println!("Adding TF2 rankings for last week ({})", last_week); rankings @@ -105,9 +105,9 @@ fn main() -> Result<(), Box> { .entry(last_week)? .or_default()? .push(RankingEntry::new("old_school_gamer", 3150))?; - + println!("\n🏆 Reading back ranking data with the same natural API..."); - + // Get today's CS2 leaderboard println!("\n🎯 CS2 Leaderboard for {} (today):", today); let mut today_rankings = rankings @@ -119,41 +119,53 @@ fn main() -> Result<(), Box> { // Sort by score descending today_rankings.sort_by(|a, b| b.score.cmp(&a.score)); - + for (rank, entry) in today_rankings.iter().enumerate() { - println!(" {}. {} - {} points", rank + 1, entry.player_id, entry.score); + println!( + " {}. {} - {} points", + rank + 1, + entry.player_id, + entry.score + ); } - + // Show cross-game analysis is still easy println!("\n🎮 Multi-game player analysis for player1 on {}:", today); - let cs2_player1_score = today_rankings.iter() + let cs2_player1_score = today_rankings + .iter() .find(|e| e.player_id == "player1") .map(|e| e.score); - let valorant_player1_score = valorant_today.to_vec()?.iter() + let valorant_player1_score = valorant_today + .to_vec()? + .iter() .find(|e| e.player_id == "player1") .map(|e| e.score); - if let Some(score) = cs2_player1_score { println!(" CS2 Score: {}", score); } - if let Some(score) = valorant_player1_score { println!(" Valorant Score: {}", score); } - + if let Some(score) = cs2_player1_score { + println!(" CS2 Score: {}", score); + } + if let Some(score) = valorant_player1_score { + println!(" Valorant Score: {}", score); + } + // Showcase the power of the nested structure for stats // For nested collections, we use the keys API instead of iter() println!("\n📊 Dynamic Database Statistics (discovered games):"); - + // Note: For nested collections, we iterate over known keys or use a different approach // since the values (nested DurableMaps) cannot be directly deserialized let games = vec!["cs2", "valorant", "tf2"]; // In a real app, you might track these separately - + for game in games { let game_history = rankings.entry(game.to_string())?.or_default()?; let active_days = game_history.len()?; - + if active_days > 0 { // For demonstration, let's count entries from known days let mut total_entries = 0; let days = [today, yesterday, last_week]; - + for day in days { if let Ok(daily_rankings) = game_history.entry(day) { if let Ok(rankings_vec) = daily_rankings.or_default() { @@ -161,14 +173,18 @@ fn main() -> Result<(), Box> { } } } - + if total_entries > 0 { - println!(" • {}: {} total entries across {} active day(s)", - game.to_uppercase(), total_entries, active_days); + println!( + " • {}: {} total entries across {} active day(s)", + game.to_uppercase(), + total_entries, + active_days + ); } } } - + println!("\n✨ Key Benefits of This Rewritten Approach:"); println!(" • No more manual key construction (`format!`) - the core goal is met!"); println!(" • The code's structure now mirrors the mental model: `rankings[game][day]`"); @@ -176,4 +192,4 @@ fn main() -> Result<(), Box> { println!(" • Demonstrates the full power of the `DurableCollection` and `entry()` design."); Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/simple_ranking.rs b/durable/examples/simple_ranking.rs index 3cc873c41f3d93c7fa9e6e2dfc80e815f456b1f9..3c35c62674d7199f9b9864192fbbacb6ac0621a1 100644 --- a/durable/examples/simple_ranking.rs +++ b/durable/examples/simple_ranking.rs @@ -4,65 +4,68 @@ fn main() -> Result<(), Box> { println!("🏆 Simple Game Ranking Example"); println!("Demonstrating the pattern from docs/motivation.md"); println!("==============================================="); - + let db = Db::open("simple_ranking_db")?; - + // This is the exact pattern from the docs: Game Mode → List of (Player, Score) // For simplicity, we're showing one day's data per game mode - let rankings: DurableMap> = DurableMap::new_nested(&db, "rankings"); - + let rankings: DurableMap> = + DurableMap::new_nested(&db, "rankings"); + println!("\n📊 Adding TF2 rankings (from the docs example)..."); - + // This is the exact code pattern shown in docs/motivation.md let mut tf2_rankings = rankings.entry("tf2".to_string())?.or_default()?; tf2_rankings.push(("player1".to_string(), 1500))?; tf2_rankings.push(("player2".to_string(), 1400))?; tf2_rankings.push(("player3".to_string(), 1300))?; - + println!("✅ Added TF2 rankings using the docs pattern!"); - + // Add some other games for comparison println!("\n📊 Adding CS2 rankings..."); let mut cs2_rankings = rankings.entry("cs2".to_string())?.or_default()?; cs2_rankings.push(("pro_player".to_string(), 2500))?; cs2_rankings.push(("skilled_gamer".to_string(), 2200))?; - + println!("✅ Added CS2 rankings!"); - + // Now read back the data println!("\n🏆 Current TF2 Leaderboard:"); let tf2_data = rankings.entry("tf2".to_string())?.or_default()?; - + // Convert to vec and sort for display let mut tf2_leaderboard = tf2_data.to_vec()?; tf2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by score descending - + for (rank, (player, score)) in tf2_leaderboard.iter().enumerate() { println!(" {}. {} - {} points", rank + 1, player, score); } - + println!("\n🏆 Current CS2 Leaderboard:"); let cs2_data = rankings.entry("cs2".to_string())?.or_default()?; - + let mut cs2_leaderboard = cs2_data.to_vec()?; cs2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1)); - + for (rank, (player, score)) in cs2_leaderboard.iter().enumerate() { println!(" {}. {} - {} points", rank + 1, player, score); } - + println!("\n📈 Database Statistics:"); println!(" TF2 has {} players", tf2_data.len()?); println!(" CS2 has {} players", cs2_data.len()?); - + println!("\n✨ This demonstrates the exact pattern from docs/motivation.md:"); println!(" rankings.entry(game_mode)?.or_default()?.push((player, score))?;"); println!(" "); println!(" Compare this to the manual key construction required with raw KV stores:"); - println!(" let key = format!(\"leaderboard:{{}}:{{}}:player:{{}}\", game_mode, day, player_id);"); + println!( + " let key = format!(\"leaderboard:{{}}:{{}}:player:{{}}\", game_mode, day, player_id);" + ); println!(" db.insert(key.as_bytes(), score.to_le_bytes())?;"); println!(" "); println!(" Durable provides the ergonomic, type-safe abstraction over RocksDB!"); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/streaming_demo.rs b/durable/examples/streaming_demo.rs index 3a74a5318675f94f89aaf01562a5b28f8a1b664a..437a37e3f820a45ad738df68ef1928d20846a028 100644 --- a/durable/examples/streaming_demo.rs +++ b/durable/examples/streaming_demo.rs @@ -2,44 +2,44 @@ use durable::{Db, DurableMap, DurableVec}; fn main() -> Result<(), Box> { let db = Db::open("streaming_demo_db")?; - + // Create collections with a moderate amount of data let mut map = DurableMap::::new(&db, "large_map")?; let mut vec = DurableVec::::new(&db, "large_vec")?; - + println!("🚀 Streaming Iterator Demo\n"); - + // Add 1000 entries to demonstrate streaming println!("Adding 1000 entries to map and vec..."); for i in 0..1000 { map.insert(i, format!("Value {}", i))?; vec.push(format!("Item {}", i))?; } - + println!("\n📊 Collection sizes:"); println!(" Map entries: {}", map.len()?); println!(" Vec elements: {}", vec.len()?); - + // Demonstrate streaming iteration - memory efficient println!("\n✨ Streaming iteration (memory efficient):"); - + // Count items without loading into memory let map_count = map.iter().count(); - println!(" Counted {} map entries without loading into memory", map_count); - + println!( + " Counted {} map entries without loading into memory", + map_count + ); + // Find specific items efficiently let target = 500; - let found = map.iter() - .find(|item| { - item.as_ref() - .map(|(k, _)| *k == target) - .unwrap_or(false) - }); - + let found = map + .iter() + .find(|item| item.as_ref().map(|(k, _)| *k == target).unwrap_or(false)); + if let Some(Ok((k, v))) = found { println!(" Found key {} with value '{}' via streaming", k, v); } - + // Process only what we need println!("\n🎯 Processing first 10 items only:"); for (i, item) in vec.iter()?.take(10).enumerate() { @@ -48,34 +48,31 @@ fn main() -> Result<(), Box> { Err(e) => println!(" [{}] Error: {:?}", i, e), } } - + // Filter and process without loading all data println!("\n🔍 Filtering even keys without loading all data:"); - let even_count = map.keys() - .filter(|item| { - item.as_ref() - .map(|k| k % 2 == 0) - .unwrap_or(false) - }) + let even_count = map + .keys() + .filter(|item| item.as_ref().map(|k| k % 2 == 0).unwrap_or(false)) .count(); println!(" Found {} even keys", even_count); - + // Compare with loading everything into memory println!("\n⚠️ Loading all data into memory (less efficient for large collections):"); let all_values = map.values_vec()?; println!(" Loaded {} values into a Vec", all_values.len()); - + println!("\n✅ Streaming iterators provide:"); println!(" • Constant memory usage regardless of collection size"); println!(" • Ability to process data larger than RAM"); println!(" • Early termination when finding specific items"); println!(" • Efficient filtering and transformation"); - + // Clean up drop(map); drop(vec); drop(db); std::fs::remove_dir_all("streaming_demo_db").ok(); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/examples/vec_example.rs b/durable/examples/vec_example.rs index 07d394b5f3964ea1942a645c2f008e9e3c37d6d8..7576bdb0c14fc57a5cc52a9a93fd9bf59ffa03af 100644 --- a/durable/examples/vec_example.rs +++ b/durable/examples/vec_example.rs @@ -1,5 +1,5 @@ use durable::{Db, DurableVec}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] struct Task { @@ -11,56 +11,57 @@ struct Task { fn main() -> Result<(), Box> { // Open or create a database let db = Db::open("example_db")?; - + // Create a persistent vector of tasks let mut tasks = DurableVec::::new(&db, "tasks")?; - + // Add some tasks tasks.push(Task { id: 1, title: "Build Durable library".to_string(), completed: true, })?; - + tasks.push(Task { id: 2, title: "Write comprehensive tests".to_string(), completed: true, })?; - + tasks.push(Task { id: 3, title: "Create documentation".to_string(), completed: false, })?; - + println!("Total tasks: {}", tasks.len()?); - + // Iterate through all tasks println!("\nAll tasks:"); for (i, task) in tasks.iter()?.enumerate() { let task = task?; - println!(" [{}] {} - {}", - i, - task.title, + println!( + " [{}] {} - {}", + i, + task.title, if task.completed { "✓" } else { "○" } ); } - + // Get a specific task if let Some(task) = tasks.get(1)? { println!("\nTask at index 1: {:?}", task); } - + // Mark the last task as completed if let Some(mut last_task) = tasks.pop()? { println!("\nCompleting task: {}", last_task.title); last_task.completed = true; tasks.push(last_task)?; } - + // The data persists even after the program exits! println!("\nData has been persisted to disk."); - + Ok(()) -} \ No newline at end of file +} diff --git a/durable/src/lib.rs b/durable/src/lib.rs index 8c46d49e27750832f19368e8f4e4a6f58ef287bc..7bfdbf20287f8e48322a99e9f935c7f4b63c4639 100644 --- a/durable/src/lib.rs +++ b/durable/src/lib.rs @@ -1,30 +1,30 @@ //! Durable - RocksDB-backed persistent data structures for Rust +use rocksdb::{Options, WriteBatch, DB as RocksDB}; use std::path::Path; use std::sync::Arc; -use rocksdb::{DB as RocksDB, Options, WriteBatch}; use thiserror::Error; -pub mod vec; pub mod map; -pub use vec::DurableVec; +pub mod vec; pub use map::DurableMap; +pub use vec::DurableVec; /// Error types for Durable operations #[derive(Error, Debug)] pub enum DurableError { #[error("RocksDB error: {0}")] RocksDB(#[from] rocksdb::Error), - + #[error("Serialization error: {0}")] Serialization(#[from] bincode::Error), - + #[error("Key not found")] KeyNotFound, - + #[error("Collection not found: {0}")] CollectionNotFound(String), - + #[error("Data corruption: {0}")] Corruption(String), } @@ -35,7 +35,7 @@ pub type Result = std::result::Result; pub trait DurableCollection { /// Creates a new instance of the collection from a database handle /// and a pre-determined, unique key prefix. - /// + /// /// This is the key method that allows `DurableMap` to instantiate /// a nested collection handle. fn from_prefix(db: Db, prefix: Vec) -> Self; @@ -53,13 +53,13 @@ impl Db { let mut opts = Options::default(); opts.create_if_missing(true); opts.create_missing_column_families(true); - + let db = RocksDB::open(&opts, path)?; - Ok(Db { + Ok(Db { inner: Arc::new(db), }) } - + /// Create a new write batch for atomic operations pub fn batch(&self) -> Batch { Batch { @@ -67,41 +67,44 @@ impl Db { inner: WriteBatch::default(), } } - + /// Get the underlying RocksDB handle (for advanced usage) pub(crate) fn rocks(&self) -> &RocksDB { &self.inner } - + /// Get a new unique collection ID for nested collections pub fn new_collection_id(&self) -> Result { let key = b"__global_meta:next_collection_id"; - + // Get current value let current_bytes = self.rocks().get(key)?; let current_id = match current_bytes { Some(bytes) => { if bytes.len() != 8 { - return Err(DurableError::Corruption("Invalid collection ID bytes size".into())); + return Err(DurableError::Corruption( + "Invalid collection ID bytes size".into(), + )); } - let id_bytes: [u8; 8] = bytes[..8].try_into() + let id_bytes: [u8; 8] = bytes[..8] + .try_into() .map_err(|_| DurableError::Corruption("Invalid collection ID bytes".into()))?; u64::from_le_bytes(id_bytes) } None => 0, }; - + let next_id = current_id + 1; - + // Try to atomically update - use compare-and-swap semantics let mut batch = WriteBatch::default(); batch.put(key, &next_id.to_le_bytes()); - + // For now, just write it directly. In a real implementation, // we'd want proper compare-and-swap to handle concurrent access self.rocks().write(batch)?; self.rocks().flush_wal(true)?; - + Ok(current_id) } } @@ -129,5 +132,4 @@ impl Batch { self.db.rocks().flush_wal(true)?; Ok(()) } - } diff --git a/durable/src/map.rs b/durable/src/map.rs index d3cc39bfede02f66532e4055299acdbffb8f6280..f7fdf224f52e791ec1204668ece9413035b4bfce 100644 --- a/durable/src/map.rs +++ b/durable/src/map.rs @@ -1,6 +1,6 @@ -use crate::{Batch, Db, Result, DurableError, DurableCollection}; -use rocksdb::{IteratorMode, WriteBatch, Direction}; -use serde::{Serialize, Deserialize}; +use crate::{Batch, Db, DurableCollection, DurableError, Result}; +use rocksdb::{Direction, IteratorMode, WriteBatch}; +use serde::{Deserialize, Serialize}; use std::marker::PhantomData; /// A persistent map backed by RocksDB @@ -10,78 +10,78 @@ pub struct DurableMap { _phantom: PhantomData<(K, V)>, } -impl DurableMap -where +impl DurableMap +where K: Serialize + for<'de> Deserialize<'de>, V: Serialize + for<'de> Deserialize<'de>, { /// Create a new DurableMap with the given name pub fn new(db: &Db, name: &str) -> Result { let prefix = format!("map:{}", name).into_bytes(); - + Ok(DurableMap { db: db.clone(), prefix, _phantom: PhantomData, }) } - + /// Insert a key-value pair into the map pub fn insert(&mut self, key: K, value: V) -> Result> { let key_bytes = bincode::serialize(&key)?; let value_bytes = bincode::serialize(&value)?; - + // Get the old value if it exists let old_value = self.get(&key)?; - + let mut batch = WriteBatch::default(); - + // Write the new value let db_key = self.entry_key(&key_bytes); batch.put(&db_key, &value_bytes); - + // Update length if this is a new key if old_value.is_none() { let new_len = self.len()? + 1; let len_key = self.meta_key("len"); batch.put(&len_key, &(new_len as u64).to_le_bytes()); } - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(old_value) } - + /// Put a key-value pair into the map without returning the old value - /// + /// /// This is more efficient than `insert` when you don't need the old value, /// as it only checks for key existence without deserializing the value. pub fn put(&mut self, key: K, value: V) -> Result<()> { let key_bytes = bincode::serialize(&key)?; let value_bytes = bincode::serialize(&value)?; let db_key = self.entry_key(&key_bytes); - + let mut batch = WriteBatch::default(); - + // Check if this is a new key (without deserializing the value) let is_new = self.db.rocks().get_pinned(&db_key)?.is_none(); - + // Write the new value batch.put(&db_key, &value_bytes); - + // Update length if this is a new key if is_new { let new_len = self.len()? + 1; let len_key = self.meta_key("len"); batch.put(&len_key, &(new_len as u64).to_le_bytes()); } - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } @@ -105,12 +105,12 @@ where Ok(()) } - + /// Get a value by key pub fn get(&self, key: &K) -> Result> { let key_bytes = bincode::serialize(key)?; let db_key = self.entry_key(&key_bytes); - + match self.db.rocks().get(&db_key)? { Some(bytes) => { let value = bincode::deserialize(&bytes)?; @@ -119,20 +119,20 @@ where None => Ok(None), } } - + /// Check if a key exists in the map pub fn contains_key(&self, key: &K) -> Result { let key_bytes = bincode::serialize(key)?; let db_key = self.entry_key(&key_bytes); - + Ok(self.db.rocks().get(&db_key)?.is_some()) } - + /// Remove a key-value pair from the map pub fn remove(&mut self, key: &K) -> Result> { let key_bytes = bincode::serialize(key)?; let db_key = self.entry_key(&key_bytes); - + // Get the old value let old_value = match self.db.rocks().get(&db_key)? { Some(bytes) => { @@ -141,36 +141,37 @@ where } None => None, }; - + // Delete the key if it existed and update length if old_value.is_some() { let mut batch = WriteBatch::default(); - + // Delete the entry batch.delete(&db_key); - + // Update length let new_len = self.len()? - 1; let len_key = self.meta_key("len"); batch.put(&len_key, &(new_len as u64).to_le_bytes()); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; } - + Ok(old_value) } - - /// Clear all entries from the map pub fn clear(&mut self) -> Result<()> { let prefix = self.entry_prefix(); let mut batch = WriteBatch::default(); - + // Collect all keys to delete - let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward)); + let iter = self + .db + .rocks() + .iterator(IteratorMode::From(&prefix, Direction::Forward)); for item in iter { let (key, _) = item?; if !key.starts_with(&prefix) { @@ -178,32 +179,35 @@ where } batch.delete(&key); } - + // Reset length to 0 let len_key = self.meta_key("len"); batch.delete(&len_key); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } - + /// Iterate over all key-value pairs using a streaming iterator pub fn iter(&self) -> MapIterator<'_, K, V> { let prefix = self.entry_prefix(); - let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward)); - + let iter = self + .db + .rocks() + .iterator(IteratorMode::From(&prefix, Direction::Forward)); + MapIterator { inner: iter, prefix, _phantom: PhantomData, } } - + /// Load all key-value pairs into a Vec - /// + /// /// Note: This loads the entire collection into memory. For large collections, /// prefer using `iter()` which streams elements. pub fn to_vec(&self) -> Result> { @@ -213,21 +217,24 @@ where } Ok(result) } - + /// Iterate over all keys using a streaming iterator pub fn keys(&self) -> KeyIterator<'_, K, V> { let prefix = self.entry_prefix(); - let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward)); - + let iter = self + .db + .rocks() + .iterator(IteratorMode::From(&prefix, Direction::Forward)); + KeyIterator { inner: iter, prefix, _phantom: PhantomData, } } - + /// Load all keys into a Vec - /// + /// /// Note: This loads all keys into memory. For large collections, /// prefer using `keys()` which streams elements. pub fn keys_vec(&self) -> Result> { @@ -237,21 +244,24 @@ where } Ok(result) } - + /// Iterate over all values using a streaming iterator pub fn values(&self) -> ValueIterator<'_, K, V> { let prefix = self.entry_prefix(); - let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward)); - + let iter = self + .db + .rocks() + .iterator(IteratorMode::From(&prefix, Direction::Forward)); + ValueIterator { inner: iter, prefix, _phantom: PhantomData, } } - + /// Load all values into a Vec - /// + /// /// Note: This loads all values into memory. For large collections, /// prefer using `values()` which streams elements. pub fn values_vec(&self) -> Result> { @@ -261,54 +271,52 @@ where } Ok(result) } - + /// Insert multiple key-value pairs in a single batch pub fn extend(&mut self, iter: I) -> Result<()> where - I: IntoIterator + I: IntoIterator, { let mut batch = WriteBatch::default(); let current_len = self.len()?; let mut new_entries = 0; - + for (key, value) in iter { let key_bytes = bincode::serialize(&key)?; let value_bytes = bincode::serialize(&value)?; let db_key = self.entry_key(&key_bytes); - + // Check if this is a new key if !self.contains_key(&key)? { new_entries += 1; } - + batch.put(&db_key, &value_bytes); } - + // Update length if we added new entries if new_entries > 0 { let new_len = current_len + new_entries; let len_key = self.meta_key("len"); batch.put(&len_key, &(new_len as u64).to_le_bytes()); } - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } - - // Helper methods - + fn entry_key(&self, key_bytes: &[u8]) -> Vec { let mut db_key = self.prefix.clone(); db_key.extend_from_slice(b":entry:"); db_key.extend_from_slice(key_bytes); db_key } - + fn entry_prefix(&self) -> Vec { let mut prefix = self.prefix.clone(); prefix.extend_from_slice(b":entry:"); @@ -321,14 +329,14 @@ impl DurableMap { /// Create a new DurableMap for nested collections (no serialization constraints) pub fn new_nested(db: &Db, name: &str) -> Self { let prefix = format!("map:{}", name).into_bytes(); - + DurableMap { db: db.clone(), prefix, _phantom: PhantomData, } } - + /// Create a new DurableMap from a prefix (used for nested collections) pub fn from_prefix(db: Db, prefix: Vec) -> Self { Self { @@ -337,7 +345,7 @@ impl DurableMap { _phantom: PhantomData, } } - + /// Get the number of entries in the map (unconstrained version for nested collections) pub fn len(&self) -> Result { let key = self.meta_key("len"); @@ -346,14 +354,15 @@ impl DurableMap { if bytes.len() != 8 { return Err(DurableError::Corruption("Invalid length bytes size".into())); } - let len_bytes: [u8; 8] = bytes[..8].try_into() + let len_bytes: [u8; 8] = bytes[..8] + .try_into() .map_err(|_| DurableError::Corruption("Invalid length bytes".into()))?; Ok(u64::from_le_bytes(len_bytes) as usize) } None => Ok(0), } } - + /// Check if the map is empty (unconstrained version for nested collections) pub fn is_empty(&self) -> Result { Ok(self.len()? == 0) @@ -366,7 +375,7 @@ impl DurableMap { db_key.extend_from_slice(key_bytes); db_key } - + fn meta_key(&self, meta_type: &str) -> Vec { let mut key = self.prefix.clone(); key.extend_from_slice(b":__meta:"); @@ -411,7 +420,7 @@ pub struct OccupiedEntry<'a, K, V> { value_marker: Vec, // The bytes read from RocksDB, e.g., [0x02, ...] } -impl<'a, K, V> OccupiedEntry<'a, K, V> +impl<'a, K, V> OccupiedEntry<'a, K, V> where V: DurableCollection, { @@ -421,8 +430,9 @@ where if self.value_marker.len() != 9 || self.value_marker[0] != 0x02 { return Err(DurableError::Corruption("Invalid collection marker".into())); } - - let col_id_bytes: [u8; 8] = self.value_marker[1..9].try_into() + + let col_id_bytes: [u8; 8] = self.value_marker[1..9] + .try_into() .map_err(|_| DurableError::Corruption("Invalid collection ID".into()))?; let col_id = u64::from_le_bytes(col_id_bytes); @@ -433,7 +443,7 @@ where // 3. Create the collection handle using the trait method Ok(V::from_prefix(self.map.db.clone(), child_prefix)) } - + /// Gets a handle to the existing nested collection (same as get but consumes self) pub fn or_default(self) -> Result { self.get() @@ -446,7 +456,7 @@ pub struct VacantEntry<'a, K, V> { key: K, // The original key from the user } -impl<'a, K, V> VacantEntry<'a, K, V> +impl<'a, K, V> VacantEntry<'a, K, V> where K: Serialize, V: DurableCollection, @@ -518,7 +528,7 @@ where V: for<'de> Deserialize<'de>, { type Item = Result<(K, V)>; - + fn next(&mut self) -> Option { match self.inner.next() { Some(Ok((db_key, value_bytes))) => { @@ -526,13 +536,16 @@ where if !db_key.starts_with(&self.prefix) { return None; } - + // Extract the key part (skip prefix) let key_start = self.prefix.len(); let key_bytes = &db_key[key_start..]; - + // Deserialize key and value - match (bincode::deserialize(key_bytes), bincode::deserialize(&value_bytes)) { + match ( + bincode::deserialize(key_bytes), + bincode::deserialize(&value_bytes), + ) { (Ok(key), Ok(value)) => Some(Ok((key, value))), (Err(e), _) | (_, Err(e)) => Some(Err(e.into())), } @@ -555,7 +568,7 @@ where K: for<'de> Deserialize<'de>, { type Item = Result; - + fn next(&mut self) -> Option { match self.inner.next() { Some(Ok((db_key, _))) => { @@ -563,11 +576,11 @@ where if !db_key.starts_with(&self.prefix) { return None; } - + // Extract the key part (skip prefix) let key_start = self.prefix.len(); let key_bytes = &db_key[key_start..]; - + // Deserialize key match bincode::deserialize(key_bytes) { Ok(key) => Some(Ok(key)), @@ -592,7 +605,7 @@ where V: for<'de> Deserialize<'de>, { type Item = Result; - + fn next(&mut self) -> Option { match self.inner.next() { Some(Ok((db_key, value_bytes))) => { @@ -600,7 +613,7 @@ where if !db_key.starts_with(&self.prefix) { return None; } - + // Deserialize value match bincode::deserialize(&value_bytes) { Ok(value) => Some(Ok(value)), @@ -616,214 +629,232 @@ where #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; use std::collections::HashMap; - + use tempfile::TempDir; + fn setup_test_db() -> (TempDir, Db) { let temp_dir = TempDir::new().unwrap(); let db = Db::open(temp_dir.path()).unwrap(); (temp_dir, db) } - + #[test] fn test_insert_and_get() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "test_map").unwrap(); - + // Insert some values assert_eq!(map.insert("one".to_string(), 1).unwrap(), None); assert_eq!(map.insert("two".to_string(), 2).unwrap(), None); assert_eq!(map.insert("three".to_string(), 3).unwrap(), None); - + // Get values assert_eq!(map.get(&"one".to_string()).unwrap(), Some(1)); assert_eq!(map.get(&"two".to_string()).unwrap(), Some(2)); assert_eq!(map.get(&"three".to_string()).unwrap(), Some(3)); assert_eq!(map.get(&"four".to_string()).unwrap(), None); - + // Update existing value assert_eq!(map.insert("two".to_string(), 22).unwrap(), Some(2)); assert_eq!(map.get(&"two".to_string()).unwrap(), Some(22)); } - + #[test] fn test_remove() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "remove_map").unwrap(); - + // Insert and remove map.insert("key".to_string(), "value".to_string()).unwrap(); - assert_eq!(map.remove(&"key".to_string()).unwrap(), Some("value".to_string())); + assert_eq!( + map.remove(&"key".to_string()).unwrap(), + Some("value".to_string()) + ); assert_eq!(map.remove(&"key".to_string()).unwrap(), None); assert_eq!(map.get(&"key".to_string()).unwrap(), None); } - + #[test] fn test_contains_key() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "contains_map").unwrap(); - + map.insert(42, "answer".to_string()).unwrap(); - + assert!(map.contains_key(&42).unwrap()); assert!(!map.contains_key(&43).unwrap()); } - + #[test] fn test_len_and_clear() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "len_map").unwrap(); - + // Empty map assert_eq!(map.len().unwrap(), 0); assert!(map.is_empty().unwrap()); - + // Add items for i in 0..10 { map.insert(i, i * 2).unwrap(); } assert_eq!(map.len().unwrap(), 10); assert!(!map.is_empty().unwrap()); - + // Clear map.clear().unwrap(); assert_eq!(map.len().unwrap(), 0); assert!(map.is_empty().unwrap()); } - + #[test] fn test_persistence() { let (temp_dir, db) = setup_test_db(); - + // Create and populate map { let mut map = DurableMap::>::new(&db, "persist_map").unwrap(); - map.insert("binary".to_string(), vec![1, 2, 3, 4, 5]).unwrap(); + map.insert("binary".to_string(), vec![1, 2, 3, 4, 5]) + .unwrap(); map.insert("data".to_string(), vec![10, 20, 30]).unwrap(); } - + // Drop the database drop(db); - + // Reopen and verify data persists { let db = Db::open(temp_dir.path()).unwrap(); let map = DurableMap::>::new(&db, "persist_map").unwrap(); - - assert_eq!(map.get(&"binary".to_string()).unwrap(), Some(vec![1, 2, 3, 4, 5])); - assert_eq!(map.get(&"data".to_string()).unwrap(), Some(vec![10, 20, 30])); + + assert_eq!( + map.get(&"binary".to_string()).unwrap(), + Some(vec![1, 2, 3, 4, 5]) + ); + assert_eq!( + map.get(&"data".to_string()).unwrap(), + Some(vec![10, 20, 30]) + ); assert_eq!(map.len().unwrap(), 2); } } - + #[test] fn test_iteration() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "iter_map").unwrap(); - + // Insert data let data = vec![ ("apple".to_string(), 1), ("banana".to_string(), 2), ("cherry".to_string(), 3), ]; - + for (k, v) in &data { map.insert(k.clone(), *v).unwrap(); } - + // Test iter() let mut items = map.to_vec().unwrap(); items.sort_by_key(|(k, _)| k.clone()); assert_eq!(items, data); - + // Test keys() let mut keys = map.keys_vec().unwrap(); keys.sort(); assert_eq!(keys, vec!["apple", "banana", "cherry"]); - + // Test values() let mut values = map.values_vec().unwrap(); values.sort(); assert_eq!(values, vec![1, 2, 3]); } - + #[test] fn test_extend() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "extend_map").unwrap(); - + // Extend from iterator let data: HashMap = vec![ (1, "one".to_string()), (2, "two".to_string()), (3, "three".to_string()), - ].into_iter().collect(); - + ] + .into_iter() + .collect(); + map.extend(data.clone()).unwrap(); - + // Verify all items were inserted for (k, v) in data { assert_eq!(map.get(&k).unwrap(), Some(v)); } assert_eq!(map.len().unwrap(), 3); } - + #[test] fn test_complex_keys() { - use serde::{Serialize, Deserialize}; - + use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct ComplexKey { id: u64, name: String, } - + let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "complex_map").unwrap(); - - let key1 = ComplexKey { id: 1, name: "first".to_string() }; - let key2 = ComplexKey { id: 2, name: "second".to_string() }; - + + let key1 = ComplexKey { + id: 1, + name: "first".to_string(), + }; + let key2 = ComplexKey { + id: 2, + name: "second".to_string(), + }; + map.insert(key1.clone(), "value1".to_string()).unwrap(); map.insert(key2.clone(), "value2".to_string()).unwrap(); - + assert_eq!(map.get(&key1).unwrap(), Some("value1".to_string())); assert_eq!(map.get(&key2).unwrap(), Some("value2".to_string())); } - + #[test] fn test_multiple_maps_same_db() { let (_temp, db) = setup_test_db(); - + let mut map1 = DurableMap::::new(&db, "map1").unwrap(); let mut map2 = DurableMap::::new(&db, "map2").unwrap(); - + // Insert different data map1.insert("shared_key".to_string(), 100).unwrap(); map2.insert("shared_key".to_string(), 200).unwrap(); - + // Verify isolation assert_eq!(map1.get(&"shared_key".to_string()).unwrap(), Some(100)); assert_eq!(map2.get(&"shared_key".to_string()).unwrap(), Some(200)); } - + #[test] fn test_streaming_iterators() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "stream_map").unwrap(); - + // Insert test data let data = vec![ ("alice".to_string(), 100), ("bob".to_string(), 200), ("charlie".to_string(), 300), ]; - + for (k, v) in &data { map.insert(k.clone(), *v).unwrap(); } - + // Test streaming iteration let mut collected = Vec::new(); for item in map.iter() { @@ -832,7 +863,7 @@ mod tests { } collected.sort_by_key(|(k, _)| k.clone()); assert_eq!(collected, data); - + // Test keys iterator let mut keys = Vec::new(); for key in map.keys() { @@ -840,7 +871,7 @@ mod tests { } keys.sort(); assert_eq!(keys, vec!["alice", "bob", "charlie"]); - + // Test values iterator let mut values = Vec::new(); for value in map.values() { @@ -848,48 +879,51 @@ mod tests { } values.sort(); assert_eq!(values, vec![100, 200, 300]); - + // Test that iterators properly handle prefix boundaries let mut map2 = DurableMap::::new(&db, "stream_map2").unwrap(); map2.insert("dave".to_string(), 400).unwrap(); - + // Each iterator should only see its own data let collected1: Vec<_> = map.iter().map(Result::unwrap).collect(); let collected2: Vec<_> = map2.iter().map(Result::unwrap).collect(); - + assert_eq!(collected1.len(), 3); assert_eq!(collected2.len(), 1); assert_eq!(collected2[0], ("dave".to_string(), 400)); } - + #[test] fn test_metadata_length_tracking() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "length_map").unwrap(); - + // Empty map assert_eq!(map.len().unwrap(), 0); assert!(map.is_empty().unwrap()); - + // Insert operations should update length - map.insert("key1".to_string(), "value1".to_string()).unwrap(); + map.insert("key1".to_string(), "value1".to_string()) + .unwrap(); assert_eq!(map.len().unwrap(), 1); - - map.insert("key2".to_string(), "value2".to_string()).unwrap(); + + map.insert("key2".to_string(), "value2".to_string()) + .unwrap(); assert_eq!(map.len().unwrap(), 2); - + // Updating existing key should not change length - map.insert("key1".to_string(), "new_value1".to_string()).unwrap(); + map.insert("key1".to_string(), "new_value1".to_string()) + .unwrap(); assert_eq!(map.len().unwrap(), 2); - + // Remove operations should update length map.remove(&"key1".to_string()).unwrap(); assert_eq!(map.len().unwrap(), 1); - + // Removing non-existent key should not change length map.remove(&"non_existent".to_string()).unwrap(); assert_eq!(map.len().unwrap(), 1); - + // Extend should update length correctly let data = vec![ ("key3".to_string(), "value3".to_string()), @@ -898,7 +932,7 @@ mod tests { ]; map.extend(data).unwrap(); assert_eq!(map.len().unwrap(), 4); // key2 + 3 new keys - + // Extend with existing keys should only count new ones let mixed_data = vec![ ("key2".to_string(), "updated_value2".to_string()), // existing @@ -906,39 +940,39 @@ mod tests { ]; map.extend(mixed_data).unwrap(); assert_eq!(map.len().unwrap(), 5); // only key6 was new - + // Clear should reset length to 0 map.clear().unwrap(); assert_eq!(map.len().unwrap(), 0); assert!(map.is_empty().unwrap()); } - + #[test] fn test_put_method() { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "put_map").unwrap(); - + // Put new entries map.put("a".to_string(), 1).unwrap(); map.put("b".to_string(), 2).unwrap(); map.put("c".to_string(), 3).unwrap(); - + // Verify entries exist and length is correct assert_eq!(map.get(&"a".to_string()).unwrap(), Some(1)); assert_eq!(map.get(&"b".to_string()).unwrap(), Some(2)); assert_eq!(map.get(&"c".to_string()).unwrap(), Some(3)); assert_eq!(map.len().unwrap(), 3); - + // Update existing entry with put map.put("b".to_string(), 20).unwrap(); assert_eq!(map.get(&"b".to_string()).unwrap(), Some(20)); assert_eq!(map.len().unwrap(), 3); // Length should not change - + // Compare put vs insert performance characteristics // put() doesn't return old value but is more efficient map.put("d".to_string(), 4).unwrap(); assert_eq!(map.len().unwrap(), 4); - + // insert() returns old value let old = map.insert("d".to_string(), 40).unwrap(); assert_eq!(old, Some(4)); @@ -950,145 +984,186 @@ mod tests { mod proptests { use super::*; use proptest::prelude::*; - use tempfile::TempDir; use std::collections::HashMap; - + use tempfile::TempDir; + fn setup_test_db() -> (TempDir, Db) { let temp_dir = TempDir::new().unwrap(); let db = Db::open(temp_dir.path()).unwrap(); (temp_dir, db) } - + proptest! { #[test] fn prop_insert_get_consistency(data: HashMap) { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "prop_map").unwrap(); - + // Insert all pairs for (k, v) in &data { map.insert(k.clone(), *v).unwrap(); } - + // Verify all can be retrieved for (k, v) in &data { prop_assert_eq!(map.get(k).unwrap(), Some(*v)); } - + // Verify length prop_assert_eq!(map.len().unwrap(), data.len()); } - + #[test] fn prop_remove_consistency(data: HashMap) { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "remove_map").unwrap(); - + // Insert all map.extend(data.clone()).unwrap(); - + // Remove all and verify for (k, v) in data { prop_assert_eq!(map.remove(&k).unwrap(), Some(v)); prop_assert_eq!(map.remove(&k).unwrap(), None); prop_assert!(!map.contains_key(&k).unwrap()); } - + prop_assert!(map.is_empty().unwrap()); } - + #[test] fn prop_clear_makes_empty(data: HashMap) { let (_temp, db) = setup_test_db(); let mut map = DurableMap::::new(&db, "clear_map").unwrap(); - + map.extend(data).unwrap(); map.clear().unwrap(); - + prop_assert_eq!(map.len().unwrap(), 0); prop_assert!(map.is_empty().unwrap()); prop_assert_eq!(map.to_vec().unwrap(), vec![]); } } - + #[test] fn test_nested_collections() { use crate::DurableVec; - + let (_temp, db) = setup_test_db(); - + // Create a map where values are DurableVec - let users_posts: DurableMap> = DurableMap::new_nested(&db, "user_posts"); - + let users_posts: DurableMap> = + DurableMap::new_nested(&db, "user_posts"); + // Test creating nested collections through the entry API - let mut alice_posts = users_posts.entry("alice".to_string()).unwrap().or_default().unwrap(); + let mut alice_posts = users_posts + .entry("alice".to_string()) + .unwrap() + .or_default() + .unwrap(); alice_posts.push(101).unwrap(); alice_posts.push(102).unwrap(); alice_posts.push(103).unwrap(); - + // Test accessing the same collection again - let alice_posts_again = users_posts.entry("alice".to_string()).unwrap().or_default().unwrap(); + let alice_posts_again = users_posts + .entry("alice".to_string()) + .unwrap() + .or_default() + .unwrap(); assert_eq!(alice_posts_again.len().unwrap(), 3); assert_eq!(alice_posts_again.get(0).unwrap(), Some(101)); assert_eq!(alice_posts_again.get(1).unwrap(), Some(102)); assert_eq!(alice_posts_again.get(2).unwrap(), Some(103)); - + // Test creating a different nested collection - let mut bob_posts = users_posts.entry("bob".to_string()).unwrap().or_default().unwrap(); + let mut bob_posts = users_posts + .entry("bob".to_string()) + .unwrap() + .or_default() + .unwrap(); bob_posts.push(201).unwrap(); bob_posts.push(202).unwrap(); - + // Verify isolation between nested collections assert_eq!(alice_posts_again.len().unwrap(), 3); assert_eq!(bob_posts.len().unwrap(), 2); - + // Test chained calls - users_posts.entry("charlie".to_string()).unwrap().or_default().unwrap().push(301).unwrap(); - let charlie_posts = users_posts.entry("charlie".to_string()).unwrap().or_default().unwrap(); + users_posts + .entry("charlie".to_string()) + .unwrap() + .or_default() + .unwrap() + .push(301) + .unwrap(); + let charlie_posts = users_posts + .entry("charlie".to_string()) + .unwrap() + .or_default() + .unwrap(); assert_eq!(charlie_posts.len().unwrap(), 1); assert_eq!(charlie_posts.get(0).unwrap(), Some(301)); } - - // Note: Nested DurableMap-in-DurableMap requires implementing Serialize/Deserialize + + // Note: Nested DurableMap-in-DurableMap requires implementing Serialize/Deserialize // for DurableMap, which is not straightforward since it contains database handles. // For now, let's focus on the more common case of Map-to-Vec nesting. - + // Deep nesting with Map -> Map -> Vec also requires DurableMap serialization // Let's skip this for now and focus on the fundamental Map -> Vec case - + #[test] fn test_nested_collection_persistence() { use crate::DurableVec; - + let (temp_dir, db) = setup_test_db(); - + // Create nested structure and populate it { - let users_data: DurableMap> = DurableMap::new_nested(&db, "users"); - let mut user1_data = users_data.entry("user1".to_string()).unwrap().or_default().unwrap(); + let users_data: DurableMap> = + DurableMap::new_nested(&db, "users"); + let mut user1_data = users_data + .entry("user1".to_string()) + .unwrap() + .or_default() + .unwrap(); user1_data.push("data1".to_string()).unwrap(); user1_data.push("data2".to_string()).unwrap(); - - let mut user2_data = users_data.entry("user2".to_string()).unwrap().or_default().unwrap(); + + let mut user2_data = users_data + .entry("user2".to_string()) + .unwrap() + .or_default() + .unwrap(); user2_data.push("other_data".to_string()).unwrap(); } - + // Drop the database drop(db); - + // Reopen and verify persistence { let db = Db::open(temp_dir.path()).unwrap(); - let users_data: DurableMap> = DurableMap::new_nested(&db, "users"); - - let user1_data = users_data.entry("user1".to_string()).unwrap().or_default().unwrap(); + let users_data: DurableMap> = + DurableMap::new_nested(&db, "users"); + + let user1_data = users_data + .entry("user1".to_string()) + .unwrap() + .or_default() + .unwrap(); assert_eq!(user1_data.len().unwrap(), 2); assert_eq!(user1_data.get(0).unwrap(), Some("data1".to_string())); assert_eq!(user1_data.get(1).unwrap(), Some("data2".to_string())); - - let user2_data = users_data.entry("user2".to_string()).unwrap().or_default().unwrap(); + + let user2_data = users_data + .entry("user2".to_string()) + .unwrap() + .or_default() + .unwrap(); assert_eq!(user2_data.len().unwrap(), 1); assert_eq!(user2_data.get(0).unwrap(), Some("other_data".to_string())); } } -} \ No newline at end of file +} diff --git a/durable/src/vec.rs b/durable/src/vec.rs index 28d08fd0786df241aaf9c01b279708e547e4c034..bb6a690e5f491687b4446dc4dc612f14eb50c031 100644 --- a/durable/src/vec.rs +++ b/durable/src/vec.rs @@ -1,6 +1,6 @@ -use crate::{Db, Result, DurableError, DurableCollection}; +use crate::{Db, DurableCollection, DurableError, Result}; use rocksdb::WriteBatch; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use std::marker::PhantomData; /// A persistent vector backed by RocksDB @@ -10,21 +10,21 @@ pub struct DurableVec { _phantom: PhantomData, } -impl DurableVec -where - T: Serialize + for<'de> Deserialize<'de> +impl DurableVec +where + T: Serialize + for<'de> Deserialize<'de>, { /// Create a new DurableVec with the given name pub fn new(db: &Db, name: &str) -> Result { let prefix = format!("vec:{}", name).into_bytes(); - + Ok(DurableVec { db: db.clone(), prefix, _phantom: PhantomData, }) } - + /// Create a new DurableVec from a prefix (used for nested collections) pub fn from_prefix(db: Db, prefix: Vec) -> Self { Self { @@ -33,7 +33,7 @@ where _phantom: PhantomData, } } - + /// Get the length of the vector pub fn len(&self) -> Result { let key = self.meta_key("len"); @@ -42,180 +42,185 @@ where if bytes.len() != 8 { return Err(DurableError::Corruption("Invalid length bytes size".into())); } - let len_bytes: [u8; 8] = bytes[..8].try_into() + let len_bytes: [u8; 8] = bytes[..8] + .try_into() .map_err(|_| DurableError::Corruption("Invalid length bytes".into()))?; Ok(u64::from_le_bytes(len_bytes) as usize) } None => Ok(0), } } - + /// Check if the vector is empty pub fn is_empty(&self) -> Result { Ok(self.len()? == 0) } - + /// Push an element to the end of the vector pub fn push(&mut self, value: T) -> Result<()> { let len = self.len()?; let mut batch = WriteBatch::default(); - + // Serialize the value let value_bytes = bincode::serialize(&value)?; - + // Write the element let elem_key = self.element_key(len); batch.put(&elem_key, &value_bytes); - + // Update the length let new_len = (len + 1) as u64; let len_key = self.meta_key("len"); batch.put(&len_key, &new_len.to_le_bytes()); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } - + /// Get an element at the given index pub fn get(&self, index: usize) -> Result> { let len = self.len()?; if index >= len { return Ok(None); } - + let key = self.element_key(index); match self.db.rocks().get(&key)? { Some(bytes) => { let value = bincode::deserialize(&bytes)?; Ok(Some(value)) } - None => Err(DurableError::Corruption( - format!("Element at index {} not found but index < len", index) - )), + None => Err(DurableError::Corruption(format!( + "Element at index {} not found but index < len", + index + ))), } } - + /// Clear all elements from the vector pub fn clear(&mut self) -> Result<()> { let len = self.len()?; let mut batch = WriteBatch::default(); - + // Delete all elements for i in 0..len { let key = self.element_key(i); batch.delete(&key); } - + // Delete the length meta key let len_key = self.meta_key("len"); batch.delete(&len_key); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } - + /// Create a streaming iterator over the vector pub fn iter(&self) -> Result> + '_> { let prefix = self.element_prefix(); - let iter = self.db.rocks().iterator(rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward)); - + let iter = self.db.rocks().iterator(rocksdb::IteratorMode::From( + &prefix, + rocksdb::Direction::Forward, + )); + Ok(VecIterator { inner: iter, prefix, _phantom: PhantomData, }) } - + /// Convert the entire vector to a Vec in memory - /// + /// /// Note: This loads the entire collection into memory. For large collections, /// prefer using `iter()` which streams elements. pub fn to_vec(&self) -> Result> { let len = self.len()?; let mut result = Vec::with_capacity(len); - + for item in self.iter()? { result.push(item?); } - + Ok(result) } - + /// Push multiple elements in a single batch pub fn extend(&mut self, iter: I) -> Result<()> where - I: IntoIterator + I: IntoIterator, { let mut batch = WriteBatch::default(); let mut len = self.len()?; - + for value in iter { let value_bytes = bincode::serialize(&value)?; let elem_key = self.element_key(len); batch.put(&elem_key, &value_bytes); len += 1; } - + // Update length let len_key = self.meta_key("len"); batch.put(&len_key, &(len as u64).to_le_bytes()); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(()) } - + /// Remove and return the last element pub fn pop(&mut self) -> Result> { let len = self.len()?; if len == 0 { return Ok(None); } - + let last_idx = len - 1; let value = self.get(last_idx)?; - + let mut batch = WriteBatch::default(); - + // Delete the last element let elem_key = self.element_key(last_idx); batch.delete(&elem_key); - + // Update length let len_key = self.meta_key("len"); batch.put(&len_key, &(last_idx as u64).to_le_bytes()); - + // Commit atomically self.db.rocks().write(batch)?; self.db.rocks().flush_wal(true)?; - + Ok(value) } - + // Helper methods - + fn element_key(&self, index: usize) -> Vec { let mut key = self.prefix.clone(); key.push(b':'); key.extend_from_slice(&(index as u64).to_be_bytes()); key } - + fn meta_key(&self, meta_type: &str) -> Vec { let mut key = self.prefix.clone(); key.extend_from_slice(b":__meta:"); key.extend_from_slice(meta_type.as_bytes()); key } - + fn element_prefix(&self) -> Vec { let mut prefix = self.prefix.clone(); prefix.push(b':'); @@ -224,9 +229,9 @@ where } // Implement the DurableCollection trait for DurableVec -impl DurableCollection for DurableVec +impl DurableCollection for DurableVec where - T: Serialize + for<'de> Deserialize<'de> + T: Serialize + for<'de> Deserialize<'de>, { fn from_prefix(db: Db, prefix: Vec) -> Self { DurableVec::from_prefix(db, prefix) @@ -242,10 +247,10 @@ pub struct VecIterator<'a, T> { impl<'a, T> Iterator for VecIterator<'a, T> where - T: for<'de> Deserialize<'de> + T: for<'de> Deserialize<'de>, { type Item = Result; - + fn next(&mut self) -> Option { loop { match self.inner.next() { @@ -254,14 +259,14 @@ where if !key.starts_with(&self.prefix) { return None; } - + // Check if this is a meta key (skip it) // The key pattern is: prefix:element_index or prefix:__meta:type // We want to skip any key that contains "__meta:" if key.windows(7).any(|w| w == b"__meta:") { continue; // Skip this key and try the next one } - + // Deserialize the value match bincode::deserialize(&value) { Ok(item) => return Some(Ok(item)), @@ -279,37 +284,37 @@ where mod tests { use super::*; use tempfile::TempDir; - + fn setup_test_db() -> (TempDir, Db) { let temp_dir = TempDir::new().unwrap(); let db = Db::open(temp_dir.path()).unwrap(); (temp_dir, db) } - + #[test] fn test_push_and_get() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "test_vec").unwrap(); - + // Push some values vec.push("first".to_string()).unwrap(); vec.push("second".to_string()).unwrap(); vec.push("third".to_string()).unwrap(); - + // Check length assert_eq!(vec.len().unwrap(), 3); - + // Get values assert_eq!(vec.get(0).unwrap(), Some("first".to_string())); assert_eq!(vec.get(1).unwrap(), Some("second".to_string())); assert_eq!(vec.get(2).unwrap(), Some("third".to_string())); assert_eq!(vec.get(3).unwrap(), None); } - + #[test] fn test_persistence() { let (temp_dir, db) = setup_test_db(); - + // Create and populate vector { let mut vec = DurableVec::::new(&db, "persist_vec").unwrap(); @@ -317,55 +322,55 @@ mod tests { vec.push(100).unwrap(); vec.push(-7).unwrap(); } - + // Drop the database drop(db); - + // Reopen and verify data persists { let db = Db::open(temp_dir.path()).unwrap(); let vec = DurableVec::::new(&db, "persist_vec").unwrap(); - + assert_eq!(vec.len().unwrap(), 3); assert_eq!(vec.get(0).unwrap(), Some(42)); assert_eq!(vec.get(1).unwrap(), Some(100)); assert_eq!(vec.get(2).unwrap(), Some(-7)); } } - + #[test] fn test_clear() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "clear_vec").unwrap(); - + // Add some elements vec.extend(vec![1, 2, 3, 4, 5]).unwrap(); assert_eq!(vec.len().unwrap(), 5); - + // Clear vec.clear().unwrap(); assert_eq!(vec.len().unwrap(), 0); assert!(vec.is_empty().unwrap()); - + // Should be able to push again vec.push(42).unwrap(); assert_eq!(vec.len().unwrap(), 1); assert_eq!(vec.get(0).unwrap(), Some(42)); } - + #[test] fn test_pop() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "pop_vec").unwrap(); - + // Empty pop assert_eq!(vec.pop().unwrap(), None); - + // Push and pop vec.push("a".to_string()).unwrap(); vec.push("b".to_string()).unwrap(); vec.push("c".to_string()).unwrap(); - + assert_eq!(vec.pop().unwrap(), Some("c".to_string())); assert_eq!(vec.len().unwrap(), 2); assert_eq!(vec.pop().unwrap(), Some("b".to_string())); @@ -374,68 +379,70 @@ mod tests { assert_eq!(vec.len().unwrap(), 0); assert_eq!(vec.pop().unwrap(), None); } - + #[test] fn test_iteration() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "iter_vec").unwrap(); - + // Add elements let values = vec![10, 20, 30, 40, 50]; vec.extend(values.clone()).unwrap(); - + // Iterate and collect let collected = vec.to_vec().unwrap(); - + assert_eq!(collected, values); } - + #[test] fn test_extend() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "extend_vec").unwrap(); - + // Extend with iterator - vec.extend(vec!["a", "b", "c"].into_iter().map(String::from)).unwrap(); + vec.extend(vec!["a", "b", "c"].into_iter().map(String::from)) + .unwrap(); assert_eq!(vec.len().unwrap(), 3); - + // Extend again - vec.extend(vec!["d", "e"].into_iter().map(String::from)).unwrap(); + vec.extend(vec!["d", "e"].into_iter().map(String::from)) + .unwrap(); assert_eq!(vec.len().unwrap(), 5); - + // Verify all elements let all = vec.to_vec().unwrap(); assert_eq!(all, vec!["a", "b", "c", "d", "e"]); } - + #[test] fn test_large_dataset() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "large_vec").unwrap(); - + // Push many elements let count = 1000; for i in 0..count { vec.push(i).unwrap(); } - + assert_eq!(vec.len().unwrap(), count as usize); - + // Verify some random accesses assert_eq!(vec.get(0).unwrap(), Some(0)); assert_eq!(vec.get(500).unwrap(), Some(500)); assert_eq!(vec.get(999).unwrap(), Some(999)); assert_eq!(vec.get(1000).unwrap(), None); - + // Verify iteration count let all_values = vec.to_vec().unwrap(); assert_eq!(all_values.len(), count as usize); } - - #[test] + + #[test] fn test_complex_types() { - use serde::{Serialize, Deserialize}; - + use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct User { id: u64, @@ -443,36 +450,36 @@ mod tests { email: String, active: bool, } - + let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "users").unwrap(); - + let user1 = User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string(), active: true, }; - + let user2 = User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string(), active: false, }; - + vec.push(user1.clone()).unwrap(); vec.push(user2.clone()).unwrap(); - + assert_eq!(vec.get(0).unwrap(), Some(user1)); assert_eq!(vec.get(1).unwrap(), Some(user2)); } - + #[test] fn test_empty_vec_operations() { let (_temp, db) = setup_test_db(); let vec = DurableVec::::new(&db, "empty_vec").unwrap(); - + // Test operations on empty vec assert_eq!(vec.len().unwrap(), 0); assert!(vec.is_empty().unwrap()); @@ -480,93 +487,93 @@ mod tests { assert_eq!(vec.get(100).unwrap(), None); assert_eq!(vec.to_vec().unwrap(), Vec::::new()); } - + #[test] fn test_multiple_vecs_same_db() { let (_temp, db) = setup_test_db(); - + // Create multiple vectors with different names let mut vec1 = DurableVec::::new(&db, "vec1").unwrap(); let mut vec2 = DurableVec::::new(&db, "vec2").unwrap(); - + // Push different data to each vec1.push("vec1_data".to_string()).unwrap(); vec2.push("vec2_data".to_string()).unwrap(); - + // Verify they don't interfere assert_eq!(vec1.get(0).unwrap(), Some("vec1_data".to_string())); assert_eq!(vec2.get(0).unwrap(), Some("vec2_data".to_string())); assert_eq!(vec1.len().unwrap(), 1); assert_eq!(vec2.len().unwrap(), 1); } - + #[test] fn test_batch_atomicity() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "batch_vec").unwrap(); - + // Add initial data vec.push(1).unwrap(); vec.push(2).unwrap(); vec.push(3).unwrap(); - + // Verify initial state assert_eq!(vec.len().unwrap(), 3); - + // Clear should be atomic - either all elements deleted or none vec.clear().unwrap(); assert_eq!(vec.len().unwrap(), 0); - + // Extend should be atomic - either all elements added or none vec.extend(vec![10, 20, 30, 40, 50]).unwrap(); assert_eq!(vec.len().unwrap(), 5); let all = vec.to_vec().unwrap(); assert_eq!(all, vec![10, 20, 30, 40, 50]); } - + #[test] fn test_unicode_strings() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "unicode_vec").unwrap(); - + let test_strings = vec![ "Hello, 世界!".to_string(), "🦀 Rust 🚀".to_string(), "Ñoño".to_string(), "🏴‍☠️ Pirates".to_string(), ]; - + vec.extend(test_strings.clone()).unwrap(); - + let retrieved = vec.to_vec().unwrap(); assert_eq!(retrieved, test_strings); } - + #[test] fn test_streaming_iterator() { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "stream_vec").unwrap(); - + // Add test data let values = vec![1, 2, 3, 4, 5]; vec.extend(values.clone()).unwrap(); - + // Test streaming iteration let mut collected = Vec::new(); for item in vec.iter().unwrap() { collected.push(item.unwrap()); } - + assert_eq!(collected, values); - + // Test that iterator properly handles prefix boundaries let mut vec2 = DurableVec::::new(&db, "stream_vec2").unwrap(); vec2.extend(vec![10, 20, 30]).unwrap(); - + // Each iterator should only see its own data let collected1: Vec<_> = vec.iter().unwrap().collect::>>().unwrap(); let collected2: Vec<_> = vec2.iter().unwrap().collect::>>().unwrap(); - + assert_eq!(collected1, values); assert_eq!(collected2, vec![10, 20, 30]); } @@ -577,82 +584,82 @@ mod proptests { use super::*; use proptest::prelude::*; use tempfile::TempDir; - + fn setup_test_db() -> (TempDir, Db) { let temp_dir = TempDir::new().unwrap(); let db = Db::open(temp_dir.path()).unwrap(); (temp_dir, db) } - + proptest! { #[test] fn prop_push_get_consistency(values: Vec) { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "prop_vec").unwrap(); - + // Push all values for value in &values { vec.push(*value).unwrap(); } - + // Verify length prop_assert_eq!(vec.len().unwrap(), values.len()); - + // Verify all values can be retrieved correctly for (i, expected) in values.iter().enumerate() { prop_assert_eq!(vec.get(i).unwrap(), Some(*expected)); } } - + #[test] fn prop_extend_iter_roundtrip(values: Vec) { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "extend_vec").unwrap(); - + // Extend with all values vec.extend(values.clone()).unwrap(); - + // Get back via iteration let retrieved = vec.to_vec().unwrap(); - + prop_assert_eq!(retrieved, values); } - + #[test] fn prop_pop_removes_last(mut values: Vec) { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "pop_vec").unwrap(); - + // Add all values vec.extend(values.clone()).unwrap(); - + // Pop values and verify while let Some(expected) = values.pop() { let popped = vec.pop().unwrap(); prop_assert_eq!(popped, Some(expected)); prop_assert_eq!(vec.len().unwrap(), values.len()); } - + // Vector should be empty prop_assert!(vec.is_empty().unwrap()); prop_assert_eq!(vec.pop().unwrap(), None); } - + #[test] fn prop_clear_makes_empty(values: Vec) { let (_temp, db) = setup_test_db(); let mut vec = DurableVec::::new(&db, "clear_vec").unwrap(); - + // Add values vec.extend(values).unwrap(); - + // Clear vec.clear().unwrap(); - + // Should be empty prop_assert_eq!(vec.len().unwrap(), 0); prop_assert!(vec.is_empty().unwrap()); prop_assert_eq!(vec.get(0).unwrap(), None); } } -} \ No newline at end of file +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e88baf106b9e6550446ae1cd13dbba561193e062..7855e6d557c0d29ac368603b23807b7f6e2379bd 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] channel = "1.88.0" +components = ["rustfmt", "clippy"] diff --git a/server/src/events.rs b/server/src/events.rs index b21cb4d6ff3fe2a217f6abca3b17dd639bf4e839..1a1525e265b56cc1c4bbab4ae6d496ce5ea42e33 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -20,9 +20,5 @@ pub enum Event { /// Register a node path in the fractal tree (no external fetch). NodeEnsured { id: String }, /// Full upstream API payload for a node (domain-specific view derived at replay/render time). - EntityImported { - id: String, - ts: i64, - payload: Value, - }, + EntityImported { id: String, ts: i64, payload: Value }, } diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs index 30dab303b9d1e763a5b9261dffff13a778e3e4e3..8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017 100644 --- a/server/src/html/vote.rs +++ b/server/src/html/vote.rs @@ -31,10 +31,7 @@ pub struct VoteQuery { } pub fn vote_href(parent: &ItemId) -> String { - format!( - "/vote?parent={}", - urlencoding::encode(parent.as_str()) - ) + format!("/vote?parent={}", urlencoding::encode(parent.as_str())) } fn vote_compare_href(parent: &ItemId, left: &ItemId, right: &ItemId) -> String { @@ -114,7 +111,12 @@ fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 { ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32 } -fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup { +fn vote_edge_history( + tree: &GlobalTree, + group: &GroupState, + left: &ItemId, + right: &ItemId, +) -> Markup { let mut votes = edge_votes(group, left, right); votes.sort_by(|a, b| b.ts.cmp(&a.ts)); let legend_left = child_title(tree, left); @@ -154,7 +156,6 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right } } - fn vote_hud_form( parent: &ItemId, left: &ItemId, @@ -200,7 +201,12 @@ fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Mar } } -fn vote_ranking_sidebar(tree: &GlobalTree, parent: &ItemId, left: &ItemId, right: &ItemId) -> Markup { +fn vote_ranking_sidebar( + tree: &GlobalTree, + parent: &ItemId, + left: &ItemId, + right: &ItemId, +) -> Markup { let empty = NodeState::default(); let node = tree.get(parent).unwrap_or(&empty); let highlighted: HashSet = [left.clone(), right.clone()].into_iter().collect(); @@ -222,11 +228,7 @@ pub(crate) fn vote_recorded_morph( ) -> JsBuilder { let pool = children_of(tree, parent); let empty = NodeState::default(); - let group = tree - .get(parent) - .unwrap_or(&empty) - .local_ranking - .clone(); + let group = tree.get(parent).unwrap_or(&empty).local_ranking.clone(); let edge_history = vote_edge_history(tree, &group, left, right); let next_pair = suggest_next(&group, left, right, &pool); let actions = vote_compare_actions(parent, next_pair.as_ref()); @@ -249,8 +251,12 @@ fn vote_compare_item_card(tree: &GlobalTree, item: &ItemId, side_class: &str) -> } } - -fn suggest_next(group: &GroupState, left: &ItemId, right: &ItemId, pool: &[ItemId]) -> Option<(ItemId, ItemId)> { +fn suggest_next( + group: &GroupState, + left: &ItemId, + right: &ItemId, + pool: &[ItemId], +) -> Option<(ItemId, ItemId)> { suggest_next_pair_in_pool(group, pool, Some((left, right))) } @@ -262,22 +268,20 @@ pub async fn vote_page( let left_param = q.left.as_deref().map(parse_item_param); let right_param = q.right.as_deref().map(parse_item_param); - let tree = state.scope_tree(&parent).unwrap_or_else(|_| GlobalTree::new()); + let tree = state + .scope_tree(&parent) + .unwrap_or_else(|_| GlobalTree::new()); let empty = NodeState::default(); let parent_node = tree.get(&parent).unwrap_or(&empty); - let (left, right) = match resolve_pair( - &tree, - &parent, - left_param.as_ref(), - right_param.as_ref(), - ) { - Ok(p) => p, - Err(e) => { - let (msg, status) = e.status_message(); - return (status, msg).into_response(); - } - }; + let (left, right) = + match resolve_pair(&tree, &parent, left_param.as_ref(), right_param.as_ref()) { + Ok(p) => p, + Err(e) => { + let (msg, status) = e.status_message(); + return (status, msg).into_response(); + } + }; let pool = children_of(&tree, &parent); let group = &parent_node.local_ranking; @@ -326,15 +330,7 @@ pub async fn vote_page( state.views.increment(path.clone()); let views = state.views.get_views(&path); - Html( - layout( - &title, - body, - views, - ) - .into_string(), - ) - .into_response() + Html(layout(&title, body, views).into_string()).into_response() } #[cfg(test)] diff --git a/server/src/pair.rs b/server/src/pair.rs index 606ffa51038a57ffacf335aa48bdb1185f8483fb..54b5d2417e9dba04ed8df422156e274c2b2f76b2 100644 --- a/server/src/pair.rs +++ b/server/src/pair.rs @@ -89,10 +89,7 @@ fn pair_priority( } /// All unordered pairs from `pool`, optionally skipping `exclude`. -fn candidate_pairs( - pool: &[ItemId], - exclude: Option<(&ItemId, &ItemId)>, -) -> Vec<(ItemId, ItemId)> { +fn candidate_pairs(pool: &[ItemId], exclude: Option<(&ItemId, &ItemId)>) -> Vec<(ItemId, ItemId)> { let mut out = Vec::new(); for i in 0..pool.len() { for j in (i + 1)..pool.len() { @@ -228,10 +225,7 @@ impl PairError { "provide both left and right, or neither", axum::http::StatusCode::BAD_REQUEST, ), - Self::NoPair => ( - "no pair available", - axum::http::StatusCode::BAD_REQUEST, - ), + Self::NoPair => ("no pair available", axum::http::StatusCode::BAD_REQUEST), } } } @@ -292,16 +286,20 @@ mod tests { "reddit.com/r/rust/d", ], ); - let ab = VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); - let cd = VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap(); + let ab = + VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + let cd = + VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap(); tree.apply_vote(&parent, ab); tree.apply_vote(&parent, cd); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); - let from_ab = chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"); - let from_cd = chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d"); + let from_ab = + chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"); + let from_cd = + chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d"); assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen); } @@ -316,7 +314,8 @@ mod tests { "reddit.com/r/rust/c", ], ); - let ab = VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + let ab = + VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); tree.apply_vote(&parent, ab); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); diff --git a/server/src/parser.rs b/server/src/parser.rs index 51aa0e0f982546aab68bd5e1ca0cb726e88b3f50..9df2dcc9313f7fe250ce3b6aa167f6ec5d57951f 100644 --- a/server/src/parser.rs +++ b/server/src/parser.rs @@ -43,10 +43,7 @@ mod tests { "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/", ) .unwrap(); - assert_eq!( - id.as_str(), - "reddit.com/r/amitheasshole/comments/1trnvdl" - ); + assert_eq!(id.as_str(), "reddit.com/r/amitheasshole/comments/1trnvdl"); } #[test] diff --git a/server/src/path_types.rs b/server/src/path_types.rs index a0d1a028b8bd3c5d1c714dea3ec597d1ac39e1bc..fafd924452f6fd85e7a5b27ed2653a19581e6e56 100644 --- a/server/src/path_types.rs +++ b/server/src/path_types.rs @@ -114,11 +114,7 @@ impl ItemId { if self.as_str().contains("://") { return self.as_str().to_string(); } - if self - .segments() - .first() - .is_some_and(|s| s.contains('.')) - { + if self.segments().first().is_some_and(|s| s.contains('.')) { format!("https://{}", self.as_str()) } else { self.as_str().to_string() @@ -146,8 +142,7 @@ impl ItemId { } pub fn from_browse_uri(path: &str) -> Option { - path.strip_prefix("/~/") - .map(ItemId::from_browse_tail) + path.strip_prefix("/~/").map(ItemId::from_browse_tail) } fn canonicalize(raw: &str) -> Option { @@ -273,10 +268,7 @@ mod tests { "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/", ) .unwrap(); - assert_eq!( - id.as_str(), - "reddit.com/r/amitheasshole/comments/1trnvdl" - ); + assert_eq!(id.as_str(), "reddit.com/r/amitheasshole/comments/1trnvdl"); } #[test] @@ -296,10 +288,7 @@ mod tests { #[test] fn parent_of_post_is_subreddit() { let id = ItemId::parse("reddit.com/r/aww/comments/1trnvdl").unwrap(); - assert_eq!( - id.parent().unwrap().as_str(), - "reddit.com/r/aww" - ); + assert_eq!(id.parent().unwrap().as_str(), "reddit.com/r/aww"); } #[test] @@ -342,7 +331,8 @@ mod tests { #[test] fn from_storage_strips_post_title_slug() { - let id = ItemId::from_storage("reddit.com/r/rust/comments/aaa/announcing_rust_199").unwrap(); + let id = + ItemId::from_storage("reddit.com/r/rust/comments/aaa/announcing_rust_199").unwrap(); assert_eq!(id.as_str(), "reddit.com/r/rust/comments/aaa"); } diff --git a/server/src/ranking.rs b/server/src/ranking.rs index 93cb4c9f5887a1e598cdc9d648751055f618adcc..a83f7be120d9420db042479222ca9efc230b6da9 100644 --- a/server/src/ranking.rs +++ b/server/src/ranking.rs @@ -68,12 +68,8 @@ pub fn connected_components_from_voted_pairs( /// there is no score cache. pub fn ranked_items(group: &GroupState) -> Vec { let n = group.idx_to_item.len(); - let scores = compute_scores_from_edges( - n, - group.edges.iter().map(|(&k, &w)| (k, w)), - MAX_ITERS, - TOL, - ); + let scores = + compute_scores_from_edges(n, group.edges.iter().map(|(&k, &w)| (k, w)), MAX_ITERS, TOL); let mut items: Vec = group .idx_to_item @@ -85,7 +81,11 @@ pub fn ranked_items(group: &GroupState) -> Vec { }) .collect(); - items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); items } @@ -216,7 +216,12 @@ pub fn compute_scores_from_edges( /// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges. /// /// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names. -pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec { +pub fn ranked_items_subset( + group: &GroupState, + idxs: &[usize], + max_iters: usize, + tol: f64, +) -> Vec { if idxs.is_empty() { return vec![]; } @@ -241,11 +246,18 @@ pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, .enumerate() .filter_map(|(j, &orig)| { let item = group.idx_to_item.get(orig)?.clone(); - Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) }) + Some(RankedItem { + item, + score: *scores.get(j).unwrap_or(&0.0), + }) }) .collect(); - items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); items } @@ -417,8 +429,10 @@ mod tests { g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c - let (comps, _) = - connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied()); + let (comps, _) = connected_components_from_voted_pairs( + g.idx_to_item.len(), + g.voted_pairs.iter().copied(), + ); assert_eq!(comps.len(), 2); // Rank each component and ensure winner is first within that component. diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs index 237411a689fb10ad1b7022ea66aee1969c7507ec..227ab3f6e8e9cce1532454450773f65daa24bfe7 100644 --- a/server/src/ui_action.rs +++ b/server/src/ui_action.rs @@ -36,9 +36,7 @@ pub enum HtmlUiAction { vote_compare: bool, }, /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success. - ParseQuery { - query: String, - }, + ParseQuery { query: String }, /// Import entity data; `POST /ui` responds with `text/event-stream` whose /// events carry JS snippets to `eval` (Idiomorph morphs), not JSON. FetchEntity { diff --git a/server/src/views.rs b/server/src/views.rs index a3712e779d4b34bca2c021a2451721c4055222c2..35c2089f4c10911318dbac3d3537d04308723f4f 100644 --- a/server/src/views.rs +++ b/server/src/views.rs @@ -140,7 +140,10 @@ async fn flush_worker_loop( } } -fn flush_dirty(memory: &MemStateLock, inner: &Arc>) -> Result<(), ViewStoreError> { +fn flush_dirty( + memory: &MemStateLock, + inner: &Arc>, +) -> Result<(), ViewStoreError> { let snapshot: Vec<(String, u64)> = { let mut mem = memory.lock().map_err(|_| ViewStoreError::Poisoned)?; if mem.dirty.is_empty() { @@ -160,9 +163,7 @@ fn flush_dirty(memory: &MemStateLock, inner: &Arc>) -> Res let inner = inner.lock().map_err(|_| ViewStoreError::Poisoned)?; let mut batch = inner.db.batch(); for (path, count) in &snapshot { - inner - .counts - .put_in_batch(&mut batch, path, count)?; + inner.counts.put_in_batch(&mut batch, path, count)?; } batch.commit()?; Ok(()) diff --git a/server/static/sorter.css b/server/static/sorter.css index bdd3d931365703705be08537a07f3ccaf975d3ea..e66a1e6c1acc473c8ff1ddb1e16e75a82d33741c 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -468,9 +468,6 @@ h1 { margin: 0; background: transparent; cursor: pointer; -} - -.vote-hud-slider input[type="range"] { --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg)); }