Skip to main content

lattice_plugin_host/
plugin_store.rs

1//! OR.1 — durable, plugin-scoped key/value storage for guests.
2//!
3//! Design: `docs/dev/architecture/org-roam.md` §4.2. Slice plan:
4//! `docs/dev/operations/slice-plans/org-roam.md` OR.1.
5//!
6//! ## Why the host holds this at all
7//!
8//! **There is no single guest instance.** `spawn_event_plugin`,
9//! `spawn_config_plugin`, `spawn_help_plugin`, `spawn_dashboard_sections` and
10//! `instantiate_grammar_plugin` are separate paths, each building its own
11//! `wasmtime::Store` with its own linear memory. A plugin that keeps state "in
12//! the guest" therefore keeps *N copies, drifting* — and the drift is invisible,
13//! because every instance stays internally consistent while answering a
14//! different question from its neighbour. Org-roam's picker would offer a note
15//! that its own `<CR>` could not open, and nothing anywhere would report an
16//! error.
17//!
18//! So the sharing point has to be host-side. [`PluginStore`] is that point: one
19//! per **manifest id**, handed to every `PluginState` built for that id, so a
20//! `put` on the event seam is visible to a `get` on the grammar seam
21//! immediately — not after a flush, not after a reload.
22//!
23//! ## What the host knows about the contents
24//!
25//! Nothing. Keys are guest-chosen strings and values are opaque bytes. The key
26//! layout (`nodes`, `n/<id>`, `b/<id>`, `f/<path>` for roam) is the *guest's*
27//! schema, and the host never parses, validates or interprets it — which is the
28//! same test `org-mode.md` §2 set for the agenda, applied to persistence, where
29//! hosts usually start learning schemas.
30//!
31//! Because keys are never turned into paths, there is no traversal to defend
32//! against. `f//etc/passwd` is a key like any other; the on-disk format
33//! length-prefixes it into one file.
34//!
35//! ## Failure policy
36//!
37//! `scan_cache.rs`'s, promoted from an agenda special case to a primitive:
38//! temp-file-and-rename so a kill mid-write leaves the previous state intact, a
39//! schema version that refuses an older shape, a size cap, flush-on-drop, and
40//! degradation to **empty** on any corruption. Never to a partial read — a
41//! store serving bytes that failed a schema check is how one starts serving
42//! plausible nonsense.
43//!
44//! ## Why the on-disk format is hand-rolled
45//!
46//! The payload is *bytes*, and every serde format in the tree encodes a
47//! `Vec<u8>` as an array of integers — a ~90 KB roam blob becomes ~110 KB of
48//! per-element tags for nothing. Byte fidelity is the entire job here, so a
49//! length-prefixed frame (30 lines, no dependency, exact) is the better fit
50//! than a dependency plus the configuration to stop it being wasteful. It also
51//! makes the corruption tests say what they mean: a truncated frame is a
52//! truncated frame.
53
54use std::collections::BTreeMap;
55use std::path::{Path, PathBuf};
56use std::sync::{Arc, Mutex};
57
58/// A shared handle to one plugin's store. Cloned into every `PluginState` built
59/// for that manifest id, which is what makes one writer visible to N readers
60/// across separate `wasmtime::Store`s.
61pub type PluginStoreHandle = Arc<Mutex<PluginStore>>;
62
63/// File magic, so a file that is not a store is rejected before its bytes are
64/// interpreted as lengths.
65const MAGIC: &[u8; 8] = b"LTSTORE\x00";
66
67/// Bumped whenever the on-disk shape changes. A mismatch discards the file
68/// rather than attempting migration — the store is rebuildable by definition
69/// (roam rescans; any other guest re-derives), and a migration path is code
70/// that runs once and is wrong forever after.
71const SCHEMA_VERSION: u32 = 1;
72
73/// Above this total value size the store is cleared rather than grown without
74/// bound. Generous against the reference corpus (roam's whole index is well
75/// under 1 MB for 706 files), so reaching it means a guest is using the store
76/// for something it was not meant for.
77///
78/// Crude on purpose, and the same reasoning as `scan_cache`'s: eviction
79/// *order* does not matter, because every legitimate user of this store can
80/// rebuild what it lost. An LRU would be bookkeeping bought with nothing.
81const MAX_TOTAL_BYTES: usize = 16 * 1024 * 1024;
82
83/// Flush after this many mutations, so a burst is written in chunks rather than
84/// held whole until the burst ends.
85const FLUSH_EVERY: usize = 64;
86
87/// A mutation also writes the store out when the last write is at least this
88/// old, so a store that changes rarely — the project list, a capture's state —
89/// is on disk the moment it changes rather than whenever the 64th change or a
90/// `Drop` comes along. A burst (a roam scan) still writes at most once per
91/// interval, plus every [`FLUSH_EVERY`].
92const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
93
94/// Every store handle the host has opened, weakly — for [`flush_all`].
95///
96/// **`Drop` alone does not survive a real exit.** A handle is cloned into every
97/// `wasmtime::Store` of its plugin, and some of those live in tasks on the
98/// process-wide runtimes, which are `static`s and are never dropped. So the last
99/// `Arc` never goes away, `Drop` never runs, and a store with fewer than
100/// [`FLUSH_EVERY`] changes was never written at all: the project plugin's
101/// remembered list vanished on every restart. The binary calls [`flush_all`] on
102/// its way out instead of relying on destructors.
103static LIVE: Mutex<Vec<std::sync::Weak<Mutex<PluginStore>>>> = Mutex::new(Vec::new());
104
105/// Track `handle` for [`flush_all`].
106pub fn register(handle: &PluginStoreHandle) {
107    let mut live = LIVE.lock().unwrap_or_else(|p| p.into_inner());
108    live.retain(|w| w.strong_count() > 0);
109    live.push(Arc::downgrade(handle));
110}
111
112/// Write out every open store with unflushed changes. Called by the binary as
113/// it exits; safe to call at any time.
114pub fn flush_all() {
115    let live: Vec<PluginStoreHandle> = LIVE
116        .lock()
117        .unwrap_or_else(|p| p.into_inner())
118        .iter()
119        .filter_map(std::sync::Weak::upgrade)
120        .collect();
121    for handle in live {
122        let mut store = handle.lock().unwrap_or_else(|p| p.into_inner());
123        if store.dirty > 0 {
124            store.flush();
125        }
126    }
127}
128
129/// One plugin's durable key/value store.
130///
131/// Construct through [`PluginStore::open`]; share through a
132/// [`PluginStoreHandle`]. Never fails to open: an unreadable, corrupt or
133/// stale file yields an empty store with a `debug` log.
134#[derive(Debug)]
135pub struct PluginStore {
136    /// The file the in-memory map is persisted to.
137    path: PathBuf,
138    /// Sorted, so `keys(prefix)` is a range scan rather than a filter over
139    /// everything — the difference matters for roam's `b/<id>` family, which
140    /// is the largest.
141    entries: BTreeMap<String, Vec<u8>>,
142    /// Sum of value lengths, maintained incrementally so the size cap does not
143    /// cost a walk per `put`.
144    bytes: usize,
145    /// Bumped on every successful mutation. Persisted, so a reader that
146    /// outlives a restart does not mistake a rebuilt store for an unchanged
147    /// one.
148    generation: u64,
149    /// Mutations since the last flush.
150    dirty: usize,
151    /// When this session last wrote the file; `None` until it first does, so
152    /// the first change is written at once.
153    last_flush: Option<std::time::Instant>,
154}
155
156impl PluginStore {
157    /// Open (or start) the store held in `dir` — the plugin's private data
158    /// directory, so two plugins cannot collide and uninstalling one removes
159    /// what it persisted.
160    ///
161    /// Never fails. Every unreadable case degrades to an empty store, which the
162    /// next write repopulates.
163    pub fn open(dir: &Path) -> Self {
164        let path = dir.join("plugin-store.bin");
165        let (entries, generation) = match std::fs::read(&path) {
166            Ok(bytes) => match decode(&bytes) {
167                Ok(decoded) => decoded,
168                Err(reason) => {
169                    tracing::debug!(
170                        path = %path.display(),
171                        %reason,
172                        "plugin store unreadable; starting empty"
173                    );
174                    (BTreeMap::new(), 0)
175                }
176            },
177            // Absent is the ordinary first-run case, not a problem.
178            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (BTreeMap::new(), 0),
179            Err(error) => {
180                tracing::debug!(
181                    path = %path.display(),
182                    %error,
183                    "plugin store could not be read; starting empty"
184                );
185                (BTreeMap::new(), 0)
186            }
187        };
188        let bytes = entries.values().map(Vec::len).sum();
189        Self {
190            path,
191            entries,
192            bytes,
193            generation,
194            dirty: 0,
195            last_flush: None,
196        }
197    }
198
199    /// Store `value` under `key`, bumping [`generation`](Self::generation).
200    ///
201    /// `Err` only for a value that cannot fit at all; a value that merely does
202    /// not fit *alongside what is there* clears the store first and is then
203    /// stored, because the alternative — refusing the newest write — leaves a
204    /// full store permanently unable to record the thing it is being asked to
205    /// remember.
206    pub fn put(&mut self, key: &str, value: Vec<u8>) -> Result<(), String> {
207        if value.len() > MAX_TOTAL_BYTES {
208            return Err(format!(
209                "store put refused: {} bytes exceeds the {MAX_TOTAL_BYTES}-byte store cap",
210                value.len()
211            ));
212        }
213        let previous = self.entries.get(key).map(Vec::len).unwrap_or(0);
214        if self.bytes - previous + value.len() > MAX_TOTAL_BYTES {
215            tracing::warn!(
216                cap = MAX_TOTAL_BYTES,
217                held = self.bytes,
218                "plugin store full; discarding it wholesale"
219            );
220            self.entries.clear();
221            self.bytes = 0;
222        }
223        self.bytes = self.bytes - self.entries.get(key).map(Vec::len).unwrap_or(0) + value.len();
224        self.entries.insert(key.to_string(), value);
225        self.mutated();
226        Ok(())
227    }
228
229    /// The bytes under `key`, or `None` when nothing is stored there.
230    pub fn get(&self, key: &str) -> Option<Vec<u8>> {
231        self.entries.get(key).cloned()
232    }
233
234    /// Forget `key`. Deleting what is not there is `Ok` — a retraction that has
235    /// already happened is not an error — but it does NOT bump the generation,
236    /// because nothing changed and a reader that rebuilt for it would be
237    /// rebuilding for nothing.
238    pub fn delete(&mut self, key: &str) -> Result<(), String> {
239        if let Some(old) = self.entries.remove(key) {
240            self.bytes -= old.len();
241            self.mutated();
242        }
243        Ok(())
244    }
245
246    /// Keys carrying `prefix`, sorted. `""` lists everything.
247    pub fn keys(&self, prefix: &str) -> Vec<String> {
248        // A range scan rather than a full filter: the map is sorted, so the
249        // matching keys are contiguous.
250        self.entries
251            .range(prefix.to_string()..)
252            .take_while(|(k, _)| k.starts_with(prefix))
253            .map(|(k, _)| k.clone())
254            .collect()
255    }
256
257    /// The mutation counter a reader compares against what it last built from.
258    pub fn generation(&self) -> u64 {
259        self.generation
260    }
261
262    /// Record a mutation and flush if enough have accumulated.
263    fn mutated(&mut self) {
264        self.generation += 1;
265        self.dirty += 1;
266        let stale = self
267            .last_flush
268            .is_none_or(|at| at.elapsed() >= FLUSH_INTERVAL);
269        if self.dirty >= FLUSH_EVERY || stale {
270            self.flush();
271        }
272    }
273
274    /// Write the store out. Best-effort: a failure is logged and dropped,
275    /// because a store that cannot be written is a store that rebuilds on the
276    /// next boot — degraded, not wrong.
277    ///
278    /// Written to a temporary file and renamed, so a kill mid-write leaves the
279    /// previous state intact rather than a truncated file the next boot has to
280    /// recognise as corrupt.
281    pub fn flush(&mut self) {
282        self.dirty = 0;
283        self.last_flush = Some(std::time::Instant::now());
284        let bytes = encode(&self.entries, self.generation);
285        if let Some(parent) = self.path.parent() {
286            let _ = std::fs::create_dir_all(parent);
287        }
288        let tmp = self.path.with_extension("bin.tmp");
289        if let Err(error) = std::fs::write(&tmp, &bytes) {
290            tracing::debug!(path = %tmp.display(), %error, "plugin store write failed");
291            return;
292        }
293        if let Err(error) = std::fs::rename(&tmp, &self.path) {
294            tracing::debug!(path = %self.path.display(), %error, "plugin store rename failed");
295            let _ = std::fs::remove_file(&tmp);
296        }
297    }
298}
299
300impl Drop for PluginStore {
301    fn drop(&mut self) {
302        if self.dirty > 0 {
303            self.flush();
304        }
305    }
306}
307
308/// The on-disk frame: magic, version, generation, count, then length-prefixed
309/// key/value pairs. Little-endian throughout; a store written on one machine is
310/// not expected to be read on another, but a fixed endianness costs nothing and
311/// removes the question.
312fn encode(entries: &BTreeMap<String, Vec<u8>>, generation: u64) -> Vec<u8> {
313    let mut out = Vec::with_capacity(64 + entries.values().map(Vec::len).sum::<usize>());
314    out.extend_from_slice(MAGIC);
315    out.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
316    out.extend_from_slice(&generation.to_le_bytes());
317    out.extend_from_slice(&(entries.len() as u64).to_le_bytes());
318    for (key, value) in entries {
319        out.extend_from_slice(&(key.len() as u32).to_le_bytes());
320        out.extend_from_slice(key.as_bytes());
321        out.extend_from_slice(&(value.len() as u32).to_le_bytes());
322        out.extend_from_slice(value);
323    }
324    out
325}
326
327/// Read a frame back, or say why it could not be. Every error path is a
328/// *reason*, not a partial map: a store is discarded wholesale or trusted
329/// wholesale, never half-read.
330fn decode(bytes: &[u8]) -> Result<(BTreeMap<String, Vec<u8>>, u64), String> {
331    let mut cursor = Cursor { bytes, at: 0 };
332    if cursor.take(MAGIC.len())? != MAGIC {
333        return Err("not a plugin store (bad magic)".into());
334    }
335    let version = cursor.u32()?;
336    if version != SCHEMA_VERSION {
337        return Err(format!(
338            "schema version {version} (expected {SCHEMA_VERSION})"
339        ));
340    }
341    let generation = cursor.u64()?;
342    let count = cursor.u64()?;
343    let mut entries = BTreeMap::new();
344    for _ in 0..count {
345        let key_len = cursor.u32()? as usize;
346        let key = std::str::from_utf8(cursor.take(key_len)?)
347            .map_err(|_| "key is not UTF-8".to_string())?
348            .to_string();
349        let value_len = cursor.u32()? as usize;
350        let value = cursor.take(value_len)?.to_vec();
351        entries.insert(key, value);
352    }
353    Ok((entries, generation))
354}
355
356/// A bounds-checked read head. Every `take` is checked, so a corrupt length
357/// prefix produces a reason string rather than a panic on the boot path.
358struct Cursor<'a> {
359    bytes: &'a [u8],
360    at: usize,
361}
362
363impl<'a> Cursor<'a> {
364    fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
365        let end = self.at.checked_add(n).ok_or("length overflow")?;
366        let slice = self.bytes.get(self.at..end).ok_or("truncated")?;
367        self.at = end;
368        Ok(slice)
369    }
370
371    fn u32(&mut self) -> Result<u32, String> {
372        let b = self.take(4)?;
373        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
374    }
375
376    fn u64(&mut self) -> Result<u64, String> {
377        let b = self.take(8)?;
378        Ok(u64::from_le_bytes([
379            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
380        ]))
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    #![allow(clippy::unwrap_used, clippy::panic)]
387
388    use super::*;
389
390    fn open(dir: &Path) -> PluginStore {
391        PluginStore::open(dir)
392    }
393
394    #[test]
395    fn a_value_round_trips() {
396        let dir = tempfile::tempdir().unwrap();
397        let mut store = open(dir.path());
398        assert!(store.get("nodes").is_none(), "nothing is stored yet");
399        store.put("nodes", vec![1, 2, 3]).unwrap();
400        assert_eq!(store.get("nodes"), Some(vec![1, 2, 3]));
401    }
402
403    /// The whole point of persisting: a restart must not rebuild.
404    #[test]
405    fn values_survive_a_restart() {
406        let dir = tempfile::tempdir().unwrap();
407        {
408            let mut store = open(dir.path());
409            store.put("n/ABC", b"a node".to_vec()).unwrap();
410            store.flush();
411        }
412        let reopened = open(dir.path());
413        assert_eq!(reopened.get("n/ABC"), Some(b"a node".to_vec()));
414    }
415
416    /// A rarely-changed store is on disk as soon as it changes — with no
417    /// `flush` and no `Drop`, which is how a real exit behaves (the handle is
418    /// held by tasks on a `static` runtime).
419    #[test]
420    fn a_first_change_is_written_at_once() {
421        let dir = tempfile::tempdir().unwrap();
422        let mut store = open(dir.path());
423        store.put("projects", b"/src/a".to_vec()).unwrap();
424        std::mem::forget(store);
425        assert_eq!(open(dir.path()).get("projects"), Some(b"/src/a".to_vec()));
426    }
427
428    /// A burst is not written per change, and `flush_all` writes what it held
429    /// back, through a handle that is never dropped.
430    #[test]
431    fn a_burst_waits_and_flush_all_writes_it() {
432        let dir = tempfile::tempdir().unwrap();
433        let handle: PluginStoreHandle = Arc::new(Mutex::new(open(dir.path())));
434        register(&handle);
435        {
436            let mut store = handle.lock().unwrap();
437            store.put("k", b"first".to_vec()).unwrap();
438            store.put("k", b"second".to_vec()).unwrap();
439        }
440        assert_eq!(
441            open(dir.path()).get("k"),
442            Some(b"first".to_vec()),
443            "the second change, a moment later, is held back"
444        );
445        flush_all();
446        assert_eq!(open(dir.path()).get("k"), Some(b"second".to_vec()));
447        std::mem::forget(handle);
448    }
449
450    /// An editor exits far more often than a guest calls `flush`.
451    #[test]
452    fn dropping_the_store_persists_it() {
453        let dir = tempfile::tempdir().unwrap();
454        {
455            let mut store = open(dir.path());
456            store.put("nodes", b"x".to_vec()).unwrap();
457            // no flush() — Drop is the only thing that can save this
458        }
459        assert_eq!(open(dir.path()).get("nodes"), Some(b"x".to_vec()));
460    }
461
462    /// The generation is what a reader on ANOTHER `wasmtime::Store` compares
463    /// against, so it has to move on writes and stand still on reads. A
464    /// generation that moved on `get` would make every reader rebuild on every
465    /// use; one that did not move on `put` would make none of them ever rebuild.
466    #[test]
467    fn generation_moves_on_mutation_and_not_on_a_read() {
468        let dir = tempfile::tempdir().unwrap();
469        let mut store = open(dir.path());
470        let start = store.generation();
471
472        store.put("a", vec![1]).unwrap();
473        let after_put = store.generation();
474        assert!(after_put > start, "a put moves the generation");
475
476        let _ = store.get("a");
477        let _ = store.keys("");
478        assert_eq!(
479            store.generation(),
480            after_put,
481            "reads must not move the generation"
482        );
483
484        store.delete("a").unwrap();
485        assert!(
486            store.generation() > after_put,
487            "a delete moves it too — a retraction is a change readers must see"
488        );
489    }
490
491    /// Deleting what is not there is not a change, so it must not make every
492    /// reader rebuild.
493    #[test]
494    fn deleting_an_absent_key_is_ok_and_moves_nothing() {
495        let dir = tempfile::tempdir().unwrap();
496        let mut store = open(dir.path());
497        let before = store.generation();
498        store.delete("never-stored").unwrap();
499        assert_eq!(store.generation(), before);
500    }
501
502    /// A restart must not restart the generation, or a reader that cached
503    /// "built from 12" would read a rebuilt store's 3 and conclude nothing had
504    /// changed.
505    #[test]
506    fn the_generation_survives_a_restart() {
507        let dir = tempfile::tempdir().unwrap();
508        let expected = {
509            let mut store = open(dir.path());
510            store.put("a", vec![1]).unwrap();
511            store.put("b", vec![2]).unwrap();
512            store.flush();
513            store.generation()
514        };
515        assert_eq!(open(dir.path()).generation(), expected);
516    }
517
518    #[test]
519    fn keys_lists_a_prefix_sorted() {
520        let dir = tempfile::tempdir().unwrap();
521        let mut store = open(dir.path());
522        store.put("n/c", vec![]).unwrap();
523        store.put("n/a", vec![]).unwrap();
524        store.put("b/z", vec![]).unwrap();
525        store.put("nodes", vec![]).unwrap();
526
527        assert_eq!(store.keys("n/"), vec!["n/a", "n/c"]);
528        assert_eq!(store.keys("b/"), vec!["b/z"]);
529        assert_eq!(store.keys("").len(), 4, "an empty prefix lists everything");
530        assert!(store.keys("zzz").is_empty());
531    }
532
533    /// A key is a key, not a path. Nothing derives a filesystem location from
534    /// it, so the shapes a path sanitiser would exist to catch are simply data.
535    #[test]
536    fn a_key_that_looks_like_a_path_is_just_a_key() {
537        let dir = tempfile::tempdir().unwrap();
538        {
539            let mut store = open(dir.path());
540            store.put("f/../../etc/passwd", b"opaque".to_vec()).unwrap();
541            store.flush();
542        }
543        assert_eq!(
544            open(dir.path()).get("f/../../etc/passwd"),
545            Some(b"opaque".to_vec()),
546            "it round-trips as data"
547        );
548        // And nothing outside the store file was created for it.
549        let written: Vec<_> = std::fs::read_dir(dir.path())
550            .unwrap()
551            .filter_map(|e| e.ok())
552            .map(|e| e.file_name())
553            .collect();
554        assert_eq!(written.len(), 1, "one file, whatever the keys look like");
555    }
556
557    /// Every failure degrades to empty, never to a partial read — a store
558    /// serving bytes that failed a check is how one starts serving plausible
559    /// nonsense.
560    #[test]
561    fn corrupt_bytes_start_empty_rather_than_failing() {
562        let dir = tempfile::tempdir().unwrap();
563        std::fs::write(dir.path().join("plugin-store.bin"), b"not a store at all").unwrap();
564        let mut store = open(dir.path());
565        assert!(store.get("nodes").is_none());
566        // …and it recovers: the next write repopulates and persists normally.
567        store.put("nodes", vec![9]).unwrap();
568        store.flush();
569        assert_eq!(open(dir.path()).get("nodes"), Some(vec![9]));
570    }
571
572    /// A truncated frame is the shape a kill mid-write would leave if writes
573    /// were not atomic. It must not be half-trusted.
574    #[test]
575    fn a_truncated_frame_starts_empty() {
576        let dir = tempfile::tempdir().unwrap();
577        {
578            let mut store = open(dir.path());
579            store.put("a", vec![1, 2, 3, 4]).unwrap();
580            store.put("b", vec![5, 6, 7, 8]).unwrap();
581            store.flush();
582        }
583        let path = dir.path().join("plugin-store.bin");
584        let full = std::fs::read(&path).unwrap();
585        std::fs::write(&path, &full[..full.len() - 3]).unwrap();
586
587        let store = open(dir.path());
588        assert!(
589            store.keys("").is_empty(),
590            "a truncated store is discarded whole, not read up to the tear"
591        );
592    }
593
594    /// A schema bump must not try to read the old shape.
595    #[test]
596    fn a_schema_mismatch_starts_empty() {
597        let dir = tempfile::tempdir().unwrap();
598        let mut stale = Vec::new();
599        stale.extend_from_slice(MAGIC);
600        stale.extend_from_slice(&(SCHEMA_VERSION + 1).to_le_bytes());
601        stale.extend_from_slice(&7u64.to_le_bytes());
602        stale.extend_from_slice(&0u64.to_le_bytes());
603        std::fs::write(dir.path().join("plugin-store.bin"), &stale).unwrap();
604
605        let store = open(dir.path());
606        assert!(store.keys("").is_empty());
607        assert_eq!(store.generation(), 0, "nor is a future generation trusted");
608    }
609
610    /// The cap clears wholesale rather than growing without bound or refusing
611    /// the newest write.
612    #[test]
613    fn the_size_cap_clears_the_store() {
614        let dir = tempfile::tempdir().unwrap();
615        let mut store = open(dir.path());
616        let chunk = vec![0u8; 4 * 1024 * 1024];
617        for i in 0..4 {
618            store.put(&format!("k{i}"), chunk.clone()).unwrap();
619        }
620        assert_eq!(store.keys("").len(), 4, "16 MiB exactly still fits");
621
622        store.put("overflow", chunk.clone()).unwrap();
623        assert_eq!(
624            store.keys(""),
625            vec!["overflow"],
626            "the cap discards wholesale and keeps the newest write"
627        );
628    }
629
630    /// A value that can never fit is refused rather than clearing a store it
631    /// could not then be stored in anyway.
632    #[test]
633    fn a_value_larger_than_the_whole_store_is_refused() {
634        let dir = tempfile::tempdir().unwrap();
635        let mut store = open(dir.path());
636        store.put("keep", vec![1]).unwrap();
637        let err = store
638            .put("huge", vec![0u8; MAX_TOTAL_BYTES + 1])
639            .expect_err("a value bigger than the cap cannot be stored");
640        assert!(err.contains("exceeds"), "{err}");
641        assert_eq!(
642            store.get("keep"),
643            Some(vec![1]),
644            "and the refusal costs nothing that was already there"
645        );
646    }
647
648    /// Overwriting must not leak the old value's size into the cap accounting —
649    /// otherwise a key rewritten often reports a store that is full of one
650    /// entry.
651    #[test]
652    fn overwriting_a_key_does_not_grow_the_accounted_size() {
653        let dir = tempfile::tempdir().unwrap();
654        let mut store = open(dir.path());
655        for _ in 0..8 {
656            store.put("nodes", vec![0u8; 4 * 1024 * 1024]).unwrap();
657        }
658        assert_eq!(
659            store.keys(""),
660            vec!["nodes"],
661            "eight rewrites of one 4 MiB key never trip a 16 MiB cap"
662        );
663    }
664}