Skip to main content

vault_core/
storage.rs

1//! Row-per-entry storage, schema v2 (Phase 30, review-01 section 2.1; ADR-0138).
2//!
3//! Schema v1 kept the whole vault as one JSON string in one row, so every write
4//! parsed and re-serialised the lot, the compare-and-swap token was a hash of the
5//! blob (two people editing *different* entries conflicted, and both branches of
6//! the conflict prompt discarded someone's changeset), `version_history` grew
7//! inside the thing written most often, and nothing could be indexed.
8//!
9//! v2 stores one row per entry and per project, a row for the category list and a
10//! row for every other top-level key. The public document API is unchanged
11//! (`load_vault` returns the same JSON, `save_vault` takes it), so the desktop app,
12//! `unv-server` and `unv` need no change; what changes is what a save *does*:
13//!
14//! * only rows whose content changed are written;
15//! * `version_history` lives in its own table and is attached on load;
16//! * every save records which rows it changed in `vault_changes`, and a writer who
17//!   read an older version is **merged**, not refused: entries it left untouched
18//!   keep whatever the other writer did, and only an entry both sides changed (or
19//!   one side changed and the other deleted) is a conflict, named in the error.
20//!
21//! ## The version token
22//!
23//! `"<seq>.<state hash>"`. The hash is over every row's `(kind, key, position,
24//! content hash)`; the sequence number says *when*, so a stale token can be turned
25//! into "which rows changed since" instead of "something changed". The token is
26//! opaque to callers, as before; a token in the old bare-hex form fails the merge
27//! lookup and is a whole-vault conflict, which is what it always was.
28//!
29//! ## What a merge can and cannot know
30//!
31//! A row's content hash (`rev`) is the identity of its content. For each row the
32//! writer sends back, the changes since its base version tell us what that row
33//! looked like when it read it (`prev_rev` of the first later change). If the
34//! writer's copy still has that hash, the writer did not touch it and the stored
35//! row wins; if the writer's copy matches the stored row, there is nothing to
36//! decide; otherwise both sides edited it. History is bounded (the newest
37//! [`KEEP_SAVES`] saves); a token older than that is a whole-vault conflict.
38
39use rusqlite::{params, Connection, OptionalExtension};
40use serde_json::{Map, Value};
41use sha2::{Digest, Sha256};
42use std::collections::{HashMap, HashSet};
43
44/// The newest saves of change history that are always kept for merging stale
45/// writers, however old they are.
46pub const KEEP_SAVES: i64 = 2000;
47
48/// Saves newer than this many days are kept too (Phase 30.2): a script saving
49/// thousands of times a day used to burn through 2,000 saves in hours, and an
50/// hour-old token then became a whole-vault conflict.
51pub const KEEP_DAYS: i64 = 30;
52
53/// An absolute ceiling, so a runaway loop cannot grow the change log without bound.
54pub const HARD_KEEP_SAVES: i64 = 200_000;
55
56/// `now` minus [`KEEP_DAYS`], in the same ISO form `at` is stored in.
57fn retention_cutoff(now: &str) -> Option<String> {
58    let t =
59        time::OffsetDateTime::parse(now, &time::format_description::well_known::Rfc3339).ok()?;
60    let c = t.checked_sub(time::Duration::days(KEEP_DAYS))?;
61    Some(format!(
62        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
63        c.year(),
64        c.month() as u8,
65        c.day(),
66        c.hour(),
67        c.minute(),
68        c.second()
69    ))
70}
71
72/// Drop change history a stale writer can no longer be merged against: older than
73/// the newest `keep` saves **and** older than [`KEEP_DAYS`], or beyond `hard`.
74/// With an unreadable `now` only the counts apply. A writer older than what is
75/// left gets a whole-vault conflict.
76fn prune_saves(conn: &Connection, seq: i64, now: &str, keep: i64, hard: i64) -> Result<(), String> {
77    let cutoff = retention_cutoff(now).unwrap_or_else(|| "0".into());
78    conn.execute(
79        "DELETE FROM vault_saves WHERE (seq <= ?1 AND at < ?2) OR seq <= ?3",
80        params![seq - keep, cutoff, seq - hard],
81    )
82    .map_err(|e| e.to_string())?;
83    conn.execute(
84        "DELETE FROM vault_changes WHERE seq < COALESCE((SELECT MIN(seq) FROM vault_saves), ?1)",
85        params![seq],
86    )
87    .map_err(|e| e.to_string())?;
88    Ok(())
89}
90
91/// How far back a stale writer can still be merged: the oldest kept save's time and
92/// how many saves are kept. `None` for a vault never saved.
93pub fn merge_window(conn: &Connection) -> Result<Option<(String, i64)>, String> {
94    conn.query_row("SELECT MIN(at), COUNT(*) FROM vault_saves", [], |r| {
95        Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
96    })
97    .map(|(at, n)| at.map(|a| (a, n)))
98    .map_err(|e| e.to_string())
99}
100
101const KIND_ENTRY: &str = "entry";
102const KIND_PROJECT: &str = "project";
103const KIND_CHUNK: &str = "chunk";
104const KIND_CATEGORIES: &str = "categories";
105const KIND_DOC: &str = "doc";
106
107/// The three top-level keys that get their own rows; everything else rides in `doc`.
108/// Joins a project row key to its chunk's key in a chunk row.
109const CHUNK_SEP: char = '\u{3}';
110
111const SPLIT_KEYS: [&str; 3] = ["api_keys", "projects", "user_categories"];
112
113pub fn init_schema(conn: &Connection) -> Result<(), String> {
114    conn.execute_batch(
115        "CREATE TABLE IF NOT EXISTS vault_rows (
116             kind TEXT NOT NULL,
117             key  TEXT NOT NULL,
118             pos  INTEGER NOT NULL,
119             data TEXT NOT NULL,
120             rev  TEXT NOT NULL,
121             PRIMARY KEY (kind, key)
122         );
123         CREATE INDEX IF NOT EXISTS vault_rows_pos ON vault_rows (kind, pos);
124         CREATE TABLE IF NOT EXISTS vault_history (
125             key  TEXT PRIMARY KEY,
126             data TEXT NOT NULL,
127             rev  TEXT NOT NULL
128         );
129         CREATE TABLE IF NOT EXISTS vault_saves (
130             seq        INTEGER PRIMARY KEY AUTOINCREMENT,
131             state_hash TEXT NOT NULL,
132             at         TEXT NOT NULL
133         );
134         CREATE TABLE IF NOT EXISTS vault_changes (
135             seq      INTEGER NOT NULL,
136             kind     TEXT NOT NULL,
137             key      TEXT NOT NULL,
138             prev_rev TEXT,
139             new_rev  TEXT
140         );
141         CREATE INDEX IF NOT EXISTS vault_changes_seq ON vault_changes (seq);",
142    )
143    .map_err(|e| e.to_string())
144}
145
146fn sha(s: &str) -> String {
147    format!("{:x}", Sha256::digest(s.as_bytes()))
148}
149
150/// SHA-256 of a value's compact JSON, without building the string. Identical to
151/// `sha(&v.to_string())`, which is what `verify` recomputes from the stored text.
152struct HashWriter(Sha256);
153
154impl std::io::Write for HashWriter {
155    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
156        self.0.update(buf);
157        Ok(buf.len())
158    }
159    fn flush(&mut self) -> std::io::Result<()> {
160        Ok(())
161    }
162}
163
164fn rev_of(v: &Value) -> String {
165    let mut w = HashWriter(Sha256::new());
166    let _ = serde_json::to_writer(&mut w, v);
167    format!("{:x}", w.0.finalize())
168}
169
170/// One storable unit of the document.
171#[derive(Clone)]
172pub struct Ent {
173    pub kind: &'static str,
174    /// Unique within `kind`.
175    pub key: String,
176    /// The JSON stored in the row (for entries, without `version_history`).
177    pub data: Value,
178    pub rev: String,
179    /// An entry's `version_history`, kept apart from its row.
180    pub history: Option<Value>,
181}
182
183impl Ent {
184    fn new(kind: &'static str, key: String, data: Value, history: Option<Value>) -> Self {
185        let rev = rev_of(&data);
186        Self {
187            kind,
188            key,
189            data,
190            rev,
191            history,
192        }
193    }
194
195    /// The entry as the rest of the code sees it: row plus `version_history`.
196    pub fn full(&self) -> Value {
197        let mut v = self.data.clone();
198        if let (Some(h), Some(o)) = (&self.history, v.as_object_mut()) {
199            o.insert("version_history".into(), h.clone());
200        }
201        v
202    }
203
204    pub fn provider(&self) -> String {
205        self.data
206            .get("provider")
207            .and_then(Value::as_str)
208            .unwrap_or("")
209            .to_string()
210    }
211}
212
213/// A row key unique within the incoming list. Duplicate cks (legacy entries with
214/// no `id` and identical provider/account/key_id) get an ordinal so they do not
215/// collapse into one row.
216fn unique_keys(cks: Vec<String>) -> Vec<String> {
217    let mut seen: HashMap<String, usize> = HashMap::new();
218    cks.into_iter()
219        .map(|ck| {
220            let n = seen.entry(ck.clone()).or_insert(0);
221            *n += 1;
222            if *n == 1 {
223                ck
224            } else {
225                format!("{ck}\u{2}{n}")
226            }
227        })
228        .collect()
229}
230
231fn project_ck(p: &Value) -> String {
232    let id = p.get("id").and_then(Value::as_str).unwrap_or("");
233    if !id.is_empty() {
234        return format!("id\u{1}{id}");
235    }
236    format!(
237        "name\u{1}{}",
238        p.get("name").and_then(Value::as_str).unwrap_or("")
239    )
240}
241
242/// Split a vault document into storable units, in document order. Consumes the
243/// document so a large vault is moved, not cloned.
244pub fn split(mut doc: Value) -> Vec<Ent> {
245    let mut out = Vec::new();
246    let mut take = |k: &str| -> Vec<Value> {
247        match doc.get_mut(k).map(Value::take) {
248            Some(Value::Array(a)) => a,
249            _ => Vec::new(),
250        }
251    };
252
253    let entries = take("api_keys");
254    let keys = unique_keys(entries.iter().map(crate::entry_ck).collect());
255    for (e, key) in entries.into_iter().zip(keys) {
256        let mut data = e;
257        let history = data
258            .as_object_mut()
259            .and_then(|o| o.remove("version_history"));
260        out.push(Ent::new(KIND_ENTRY, key, data, history));
261    }
262    let projects = take("projects");
263    let keys = unique_keys(projects.iter().map(project_ck).collect());
264    let mut chunk_rows = Vec::new();
265    for (mut p, key) in projects.into_iter().zip(keys) {
266        // Phase 30.2 (ADR-0146): a project's chunks are rows of their own, so two people
267        // editing different chunks do not touch the same row. The project row keeps
268        // an empty `chunks` array to say the chunks live elsewhere.
269        if let Some(Value::Array(chunks)) = p.get_mut("chunks").map(Value::take) {
270            p["chunks"] = Value::Array(Vec::new());
271            let cks = unique_keys(
272                chunks
273                    .iter()
274                    .enumerate()
275                    .map(|(i, c)| match c.get("id").and_then(Value::as_str) {
276                        Some(id) if !id.is_empty() => format!("id\u{1}{id}"),
277                        _ => format!("pos\u{1}{i}"),
278                    })
279                    .collect(),
280            );
281            for (c, ck) in chunks.into_iter().zip(cks) {
282                chunk_rows.push(Ent::new(
283                    KIND_CHUNK,
284                    format!("{key}{CHUNK_SEP}{ck}"),
285                    c,
286                    None,
287                ));
288            }
289        }
290        out.push(Ent::new(KIND_PROJECT, key, p, None));
291    }
292    out.extend(chunk_rows);
293    let cats = take("user_categories");
294    out.push(Ent::new(
295        KIND_CATEGORIES,
296        String::new(),
297        Value::Array(cats),
298        None,
299    ));
300    let mut extra = Map::new();
301    if let Value::Object(o) = doc {
302        for (k, v) in o {
303            if !SPLIT_KEYS.contains(&k.as_str()) {
304                extra.insert(k, v);
305            }
306        }
307    }
308    out.push(Ent::new(
309        KIND_DOC,
310        String::new(),
311        Value::Object(extra),
312        None,
313    ));
314    out
315}
316
317/// Reassemble the document from units in order, consuming them.
318pub fn join(ents: Vec<Ent>) -> Value {
319    let mut doc = Map::new();
320    let mut entries = Vec::new();
321    let mut projects: Vec<(String, Value)> = Vec::new();
322    let mut chunks: HashMap<String, Vec<Value>> = HashMap::new();
323    let mut cats = Value::Array(vec![]);
324    for e in ents {
325        match e.kind {
326            KIND_ENTRY => {
327                let mut v = e.data;
328                if let (Some(h), Some(o)) = (e.history, v.as_object_mut()) {
329                    o.insert("version_history".into(), h);
330                }
331                entries.push(v);
332            }
333            KIND_PROJECT => projects.push((e.key, e.data)),
334            KIND_CHUNK => {
335                if let Some((pk, _)) = e.key.split_once(CHUNK_SEP) {
336                    chunks.entry(pk.to_string()).or_default().push(e.data);
337                }
338            }
339            KIND_CATEGORIES => cats = e.data,
340            KIND_DOC => {
341                if let Value::Object(o) = e.data {
342                    for (k, v) in o {
343                        doc.insert(k, v);
344                    }
345                }
346            }
347            _ => {}
348        }
349    }
350    doc.insert("api_keys".into(), Value::Array(entries));
351    let projects = projects
352        .into_iter()
353        .map(|(key, mut p)| {
354            // Rows win over a legacy inline array (a v2 vault not yet re-saved).
355            if let Some(c) = chunks.remove(&key) {
356                p["chunks"] = Value::Array(c);
357            }
358            p
359        })
360        .collect();
361    doc.insert("projects".into(), Value::Array(projects));
362    doc.insert("user_categories".into(), cats);
363    Value::Object(doc)
364}
365
366// ── Reading ──────────────────────────────────────────────────────────────────
367
368/// Parse many JSON strings, on worker threads once there are enough of them to
369/// pay for the threads (Phase 30.2: a full load of a large vault is dominated by
370/// parsing, and every string is independent).
371fn par_parse(texts: Vec<String>) -> Vec<Result<Value, serde_json::Error>> {
372    const MIN_PER_THREAD: usize = 512;
373    let threads = std::thread::available_parallelism()
374        .map(|n| n.get())
375        .unwrap_or(1)
376        .min(texts.len() / MIN_PER_THREAD)
377        .max(1);
378    if threads == 1 {
379        return texts.iter().map(|t| serde_json::from_str(t)).collect();
380    }
381    let per = texts.len().div_ceil(threads);
382    std::thread::scope(|sc| {
383        let handles: Vec<_> = texts
384            .chunks(per)
385            .map(|c| {
386                sc.spawn(move || {
387                    c.iter()
388                        .map(|t| serde_json::from_str(t))
389                        .collect::<Vec<_>>()
390                })
391            })
392            .collect();
393        handles
394            .into_iter()
395            .flat_map(|h| h.join().unwrap_or_default())
396            .collect()
397    })
398}
399
400fn history_map(conn: &Connection) -> Result<HashMap<String, Value>, String> {
401    let mut stmt = conn
402        .prepare("SELECT key, data FROM vault_history")
403        .map_err(|e| e.to_string())?;
404    let rows = stmt
405        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
406        .map_err(|e| e.to_string())?;
407    let (keys, texts): (Vec<String>, Vec<String>) = rows
408        .collect::<Result<Vec<_>, _>>()
409        .map_err(|e| e.to_string())?
410        .into_iter()
411        .unzip();
412    Ok(keys
413        .into_iter()
414        .zip(par_parse(texts))
415        .filter_map(|(k, v)| v.ok().map(|v| (k, v)))
416        .collect())
417}
418
419fn ent_from_row(
420    kind: &'static str,
421    key: String,
422    data: &str,
423    rev: String,
424    history: Option<Value>,
425) -> Result<Ent, String> {
426    Ok(Ent {
427        kind,
428        key,
429        data: serde_json::from_str(data).map_err(|e| e.to_string())?,
430        rev,
431        history,
432    })
433}
434
435fn kind_static(k: &str) -> &'static str {
436    match k {
437        KIND_ENTRY => KIND_ENTRY,
438        KIND_PROJECT => KIND_PROJECT,
439        KIND_CHUNK => KIND_CHUNK,
440        KIND_CATEGORIES => KIND_CATEGORIES,
441        _ => KIND_DOC,
442    }
443}
444
445/// Every stored unit in document order, or `None` when nothing has been saved.
446pub fn load_ents(conn: &Connection) -> Result<Option<Vec<Ent>>, String> {
447    load_ents_opts(conn, true)
448}
449
450/// As [`load_ents`], optionally leaving out each entry's `version_history`, which
451/// is up to 50 revisions of secret material per entry and is not wanted by a
452/// selective read.
453pub fn load_ents_opts(conn: &Connection, with_history: bool) -> Result<Option<Vec<Ent>>, String> {
454    let mut stmt = conn
455        .prepare("SELECT kind, key, data, rev FROM vault_rows ORDER BY kind, pos")
456        .map_err(|e| e.to_string())?;
457    let rows = stmt
458        .query_map([], |r| {
459            Ok((
460                r.get::<_, String>(0)?,
461                r.get::<_, String>(1)?,
462                r.get::<_, String>(2)?,
463                r.get::<_, String>(3)?,
464            ))
465        })
466        .map_err(|e| e.to_string())?;
467    let mut hist = if with_history {
468        history_map(conn)?
469    } else {
470        HashMap::new()
471    };
472    let mut stored = Vec::new();
473    let mut texts = Vec::new();
474    for r in rows {
475        let (kind, key, data, rev) = r.map_err(|e| e.to_string())?;
476        stored.push((kind, key, rev));
477        texts.push(data);
478    }
479    let mut by_kind: HashMap<&'static str, Vec<Ent>> = HashMap::new();
480    for ((kind, key, rev), parsed) in stored.into_iter().zip(par_parse(texts)) {
481        let k = kind_static(&kind);
482        let h = if k == KIND_ENTRY {
483            hist.remove(&key)
484        } else {
485            None
486        };
487        by_kind.entry(k).or_default().push(Ent {
488            kind: k,
489            key,
490            data: parsed.map_err(|e| e.to_string())?,
491            rev,
492            history: h,
493        });
494    }
495    if !by_kind.contains_key(KIND_DOC) {
496        return Ok(None); // the doc row exists iff something was ever saved
497    }
498    let mut out = Vec::new();
499    for k in [
500        KIND_ENTRY,
501        KIND_PROJECT,
502        KIND_CHUNK,
503        KIND_CATEGORIES,
504        KIND_DOC,
505    ] {
506        out.extend(by_kind.remove(k).unwrap_or_default());
507    }
508    Ok(Some(out))
509}
510
511pub fn load(conn: &Connection) -> Result<Option<Value>, String> {
512    Ok(load_ents(conn)?.map(join))
513}
514
515/// The document without any entry's `version_history`.
516pub fn load_lite(conn: &Connection) -> Result<Option<Value>, String> {
517    Ok(load_ents_opts(conn, false)?.map(join))
518}
519
520/// Every row's `(rev, pos)`, read once per save.
521pub type Snapshot = HashMap<(String, String), (String, i64)>;
522
523pub fn snapshot(conn: &Connection) -> Result<Snapshot, String> {
524    let mut stmt = conn
525        .prepare("SELECT kind, key, pos, rev FROM vault_rows")
526        .map_err(|e| e.to_string())?;
527    let rows = stmt
528        .query_map([], |r| {
529            Ok((
530                r.get::<_, String>(0)?,
531                r.get::<_, String>(1)?,
532                r.get::<_, i64>(2)?,
533                r.get::<_, String>(3)?,
534            ))
535        })
536        .map_err(|e| e.to_string())?;
537    let mut m = HashMap::new();
538    for r in rows {
539        let (k, key, pos, rev) = r.map_err(|e| e.to_string())?;
540        m.insert((k, key), (rev, pos));
541    }
542    Ok(m)
543}
544
545/// One stored row by key, with its history attached for entries.
546pub fn load_ent(conn: &Connection, kind: &'static str, key: &str) -> Result<Option<Ent>, String> {
547    let row: Option<(String, String)> = conn
548        .query_row(
549            "SELECT data, rev FROM vault_rows WHERE kind = ?1 AND key = ?2",
550            params![kind, key],
551            |r| Ok((r.get(0)?, r.get(1)?)),
552        )
553        .optional()
554        .map_err(|e| e.to_string())?;
555    let Some((data, rev)) = row else {
556        return Ok(None);
557    };
558    let history: Option<Value> = if kind == KIND_ENTRY {
559        conn.query_row(
560            "SELECT data FROM vault_history WHERE key = ?1",
561            params![key],
562            |r| r.get::<_, String>(0),
563        )
564        .optional()
565        .map_err(|e| e.to_string())?
566        .and_then(|s| serde_json::from_str(&s).ok())
567    } else {
568        None
569    };
570    Ok(Some(ent_from_row(
571        kind,
572        key.to_string(),
573        &data,
574        rev,
575        history,
576    )?))
577}
578
579/// One entry's `version_history`, read without loading anything else (Phase 30.2).
580/// `None` when no entry has that id; an empty array when it has no history.
581pub fn entry_history(conn: &Connection, id: &str) -> Result<Option<Value>, String> {
582    let key = format!("id\u{1}{id}");
583    let exists: bool = conn
584        .query_row(
585            "SELECT 1 FROM vault_rows WHERE kind = 'entry' AND key = ?1",
586            params![key],
587            |_| Ok(true),
588        )
589        .optional()
590        .map_err(|e| e.to_string())?
591        .unwrap_or(false);
592    if !exists {
593        return Ok(None);
594    }
595    let text: Option<String> = conn
596        .query_row(
597            "SELECT data FROM vault_history WHERE key = ?1",
598            params![key],
599            |r| r.get(0),
600        )
601        .optional()
602        .map_err(|e| e.to_string())?;
603    Ok(Some(match text {
604        Some(t) => serde_json::from_str(&t).map_err(|e| e.to_string())?,
605        None => Value::Array(Vec::new()),
606    }))
607}
608
609// ── Version token ────────────────────────────────────────────────────────────
610
611/// The state hash is the XOR of a hash of every row's `(kind, key, pos, rev)`.
612/// XOR is order-independent and every row is unique, so a save can update it by
613/// removing the old row's hash and adding the new one: O(changed), not O(vault).
614/// `verify` recomputes it from the rows and compares.
615type Acc = [u8; 32];
616
617fn row_hash(kind: &str, key: &str, pos: i64, rev: &str) -> Acc {
618    let mut h = Sha256::new();
619    h.update(kind.as_bytes());
620    h.update([1]);
621    h.update(key.as_bytes());
622    h.update([1]);
623    h.update(pos.to_string().as_bytes());
624    h.update([1]);
625    h.update(rev.as_bytes());
626    h.finalize().into()
627}
628
629fn xor(acc: &mut Acc, other: &Acc) {
630    for (a, b) in acc.iter_mut().zip(other) {
631        *a ^= b;
632    }
633}
634
635fn acc_from_rows(snap: &Snapshot) -> Acc {
636    let mut acc = [0u8; 32];
637    for ((k, key), (rev, pos)) in snap {
638        xor(&mut acc, &row_hash(k, key, *pos, rev));
639    }
640    acc
641}
642
643fn acc_hex(acc: &Acc) -> String {
644    acc.iter().map(|b| format!("{b:02x}")).collect()
645}
646
647fn acc_parse(s: &str) -> Option<Acc> {
648    if s.len() != 64 {
649        return None;
650    }
651    let mut out = [0u8; 32];
652    for (i, o) in out.iter_mut().enumerate() {
653        *o = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
654    }
655    Some(out)
656}
657
658fn stored_acc(conn: &Connection) -> Result<Option<Acc>, String> {
659    Ok(conn
660        .query_row(
661            "SELECT value FROM vault_meta WHERE key = 'state_acc'",
662            [],
663            |r| r.get::<_, String>(0),
664        )
665        .optional()
666        .map_err(|e| e.to_string())?
667        .and_then(|s| acc_parse(&s)))
668}
669
670fn token(seq: i64, hash: &str) -> String {
671    format!("{seq}.{hash}")
672}
673
674fn parse_token(t: &str) -> Option<(i64, &str)> {
675    let (s, h) = t.split_once('.')?;
676    Some((s.parse().ok()?, h))
677}
678
679/// The current version token, or `None` for an empty vault.
680pub fn version(conn: &Connection) -> Result<Option<String>, String> {
681    conn.query_row(
682        "SELECT value FROM vault_meta WHERE key = 'data_hash'",
683        [],
684        |r| r.get(0),
685    )
686    .optional()
687    .map_err(|e| e.to_string())
688}
689
690/// Integrity: every row hashes to its `rev`, and the rows hash to the stored token.
691pub fn verify(conn: &Connection) -> Result<bool, String> {
692    let mut stmt = conn
693        .prepare("SELECT data, rev FROM vault_rows")
694        .map_err(|e| e.to_string())?;
695    let rows = stmt
696        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
697        .map_err(|e| e.to_string())?;
698    let mut any = false;
699    for r in rows {
700        let (data, rev) = r.map_err(|e| e.to_string())?;
701        any = true;
702        if sha(&data) != rev {
703            return Ok(false);
704        }
705    }
706    if !any {
707        return Ok(true);
708    }
709    let Some(stored) = version(conn)? else {
710        return Ok(true);
711    };
712    let fresh = acc_hex(&acc_from_rows(&snapshot(conn)?));
713    let recorded = stored_acc(conn)?.map(|a| acc_hex(&a));
714    Ok(parse_token(&stored)
715        .map(|(_, h)| h == fresh)
716        .unwrap_or(false)
717        && recorded.as_deref().map(|r| r == fresh).unwrap_or(true))
718}
719
720// ── Merge ────────────────────────────────────────────────────────────────────
721
722type Id = (String, String);
723
724/// Resolve the writer's units against what is stored. Returns the units to
725/// persist, or the conflicting row labels.
726///
727/// `expect` is the version the writer read. `None` writes unconditionally.
728pub fn merge(
729    conn: &Connection,
730    stored: &Snapshot,
731    incoming: Vec<Ent>,
732    expect: Option<&str>,
733) -> Result<(Vec<Ent>, bool), String> {
734    let cur_version = version(conn)?;
735    let expect = expect.map(|e| e.strip_suffix(crate::MERGED_SUFFIX).unwrap_or(e));
736    // Rows changed since the writer's base, by the first prior revision they had.
737    let mut touched: HashMap<Id, Option<String>> = HashMap::new();
738    match expect {
739        None => {}
740        Some(e) if cur_version.as_deref() == Some(e) => {}
741        Some(e) => {
742            let ok = parse_token(e).and_then(|(seq, hash)| {
743                let found: Option<String> = conn
744                    .query_row(
745                        "SELECT state_hash FROM vault_saves WHERE seq = ?1",
746                        params![seq],
747                        |r| r.get(0),
748                    )
749                    .optional()
750                    .ok()
751                    .flatten();
752                (found.as_deref() == Some(hash)).then_some(seq)
753            });
754            let Some(base) = ok else {
755                return Err(conflict(&[]));
756            };
757            let mut stmt = conn
758                .prepare("SELECT kind, key, prev_rev FROM vault_changes WHERE seq > ?1 ORDER BY seq ASC, rowid ASC")
759                .map_err(|e| e.to_string())?;
760            let rows = stmt
761                .query_map(params![base], |r| {
762                    Ok((
763                        r.get::<_, String>(0)?,
764                        r.get::<_, String>(1)?,
765                        r.get::<_, Option<String>>(2)?,
766                    ))
767                })
768                .map_err(|e| e.to_string())?;
769            for r in rows {
770                let (k, key, prev) = r.map_err(|e| e.to_string())?;
771                touched.entry((k, key)).or_insert(prev);
772            }
773        }
774    }
775    if touched.is_empty() {
776        return Ok((incoming, false)); // nothing concurrent: the writer's document wins as written
777    }
778
779    let mut conflicts: Vec<String> = Vec::new();
780    let mut out: Vec<Ent> = Vec::new();
781    let mut seen: HashSet<Id> = HashSet::new();
782    // True when the result differs from the writer's own view of the vault, so the
783    // writer must not adopt the new version as its base (see `save_vault_txn`).
784    let mut adopted_theirs = false;
785
786    for ent in incoming {
787        let id: Id = (ent.kind.to_string(), ent.key.clone());
788        seen.insert(id.clone());
789        let Some(base_prev) = touched.get(&id) else {
790            out.push(ent);
791            continue;
792        };
793        let cur = stored.get(&id).map(|(rev, _)| rev);
794        if cur == Some(&ent.rev) {
795            out.push(ent); // both sides arrived at the same content
796            continue;
797        }
798        match base_prev {
799            // The writer did not touch it: whatever the other side did stands.
800            Some(b) if *b == ent.rev => {
801                adopted_theirs = true;
802                if cur.is_some() {
803                    if let Some(theirs) = load_ent(conn, ent.kind, &ent.key)? {
804                        out.push(theirs);
805                    }
806                } // else the other side deleted it and the writer left it alone
807            }
808            _ => conflicts.push(label(&ent)),
809        }
810    }
811    // Rows the writer does not have, in a stable order.
812    let mut leftovers: Vec<&Id> = stored.keys().filter(|id| !seen.contains(*id)).collect();
813    leftovers.sort();
814    for id in leftovers {
815        let kind = kind_static(&id.0);
816        match touched.get(id) {
817            None => {} // existed at the base, untouched: the writer deleted it
818            Some(None) => {
819                // Created after the writer read: not theirs to delete.
820                adopted_theirs = true;
821                if let Some(theirs) = load_ent(conn, kind, &id.1)? {
822                    out.push(theirs);
823                }
824            }
825            Some(Some(_)) => conflicts.push(
826                load_ent(conn, kind, &id.1)?
827                    .map(|e| label(&e))
828                    .unwrap_or_else(|| id.1.clone()),
829            ),
830        }
831    }
832    if !conflicts.is_empty() {
833        conflicts.sort();
834        conflicts.dedup();
835        return Err(conflict(&conflicts));
836    }
837    // Keep the writer's order, with rows they did not have after their own kind.
838    let order = |k: &str| match k {
839        KIND_ENTRY => 0,
840        KIND_PROJECT => 1,
841        KIND_CHUNK => 2,
842        KIND_CATEGORIES => 3,
843        _ => 4,
844    };
845    // A chunk whose project is gone (the other writer deleted it, ours added a
846    // chunk) would be an orphan row nothing reads: drop it.
847    let live: HashSet<String> = out
848        .iter()
849        .filter(|e| e.kind == KIND_PROJECT)
850        .map(|e| e.key.clone())
851        .collect();
852    out.retain(|e| {
853        e.kind != KIND_CHUNK
854            || e.key
855                .split_once(CHUNK_SEP)
856                .is_some_and(|(pk, _)| live.contains(pk))
857    });
858    out.sort_by_key(|e| order(e.kind)); // stable: preserves relative order within a kind
859    Ok((out, adopted_theirs))
860}
861
862fn label(e: &Ent) -> String {
863    match e.kind {
864        KIND_ENTRY => {
865            let p = e.provider();
866            if p.is_empty() {
867                e.key.replace('\u{1}', ":")
868            } else {
869                p
870            }
871        }
872        KIND_PROJECT => e
873            .data
874            .get("name")
875            .and_then(Value::as_str)
876            .unwrap_or(&e.key)
877            .to_string(),
878        KIND_CHUNK => {
879            let name = e.data.get("name").and_then(Value::as_str).unwrap_or("");
880            let project = e.key.split_once(CHUNK_SEP).map_or("", |(pk, _)| pk);
881            let project = project.split_once('\u{1}').map_or(project, |(_, v)| v);
882            format!(
883                "{project}: {}",
884                if name.is_empty() { "a chunk" } else { name }
885            )
886        }
887        KIND_CATEGORIES => "categories".into(),
888        _ => "vault settings".into(),
889    }
890}
891
892fn conflict(names: &[String]) -> String {
893    if names.is_empty() {
894        format!(
895            "{}: the vault changed since you last read it — reload and retry",
896            crate::CONFLICT_ERR
897        )
898    } else {
899        format!(
900            "{}: the vault changed since you last read it — you and another writer both changed: {}",
901            crate::CONFLICT_ERR,
902            names.join(", ")
903        )
904    }
905}
906
907// ── Writing ──────────────────────────────────────────────────────────────────
908
909/// Persist `ents` as the new state. Must run inside a write transaction. Only rows
910/// whose content, position or history changed are touched. Returns the new token
911/// (the current one, unchanged, when nothing differs).
912pub fn write(
913    conn: &Connection,
914    stored: &Snapshot,
915    ents: &[Ent],
916    now: &str,
917) -> Result<String, String> {
918    // The state accumulator before any row moves; updated row by row below.
919    let mut acc = match stored_acc(conn)? {
920        Some(a) => a,
921        None => acc_from_rows(stored),
922    };
923    let stored_hist: HashMap<String, String> = {
924        let mut stmt = conn
925            .prepare("SELECT key, rev FROM vault_history")
926            .map_err(|e| e.to_string())?;
927        let rows = stmt
928            .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
929            .map_err(|e| e.to_string())?;
930        let mut m = HashMap::new();
931        for r in rows {
932            let (k, v) = r.map_err(|e| e.to_string())?;
933            m.insert(k, v);
934        }
935        m
936    };
937
938    let mut changes: Vec<(String, String, Option<String>, Option<String>)> = Vec::new();
939    let mut dirty = false;
940    let mut pos_by_kind: HashMap<&str, i64> = HashMap::new();
941    let mut keep: HashSet<Id> = HashSet::new();
942    let mut keep_hist: HashSet<String> = HashSet::new();
943
944    for e in ents {
945        let p = pos_by_kind.entry(e.kind).or_insert(0);
946        let pos = *p;
947        *p += 1;
948        let id: Id = (e.kind.to_string(), e.key.clone());
949        keep.insert(id.clone());
950        match stored.get(&id) {
951            None => {
952                conn.execute(
953                    "INSERT INTO vault_rows (kind, key, pos, data, rev) VALUES (?1, ?2, ?3, ?4, ?5)",
954                    params![e.kind, e.key, pos, e.data.to_string(), e.rev],
955                )
956                .map_err(|x| x.to_string())?;
957                xor(&mut acc, &row_hash(e.kind, &e.key, pos, &e.rev));
958                changes.push((e.kind.into(), e.key.clone(), None, Some(e.rev.clone())));
959                dirty = true;
960            }
961            Some((rev, old_pos)) if *rev != e.rev => {
962                xor(&mut acc, &row_hash(e.kind, &e.key, *old_pos, rev));
963                xor(&mut acc, &row_hash(e.kind, &e.key, pos, &e.rev));
964                conn.execute(
965                    "UPDATE vault_rows SET pos = ?3, data = ?4, rev = ?5 WHERE kind = ?1 AND key = ?2",
966                    params![e.kind, e.key, pos, e.data.to_string(), e.rev],
967                )
968                .map_err(|x| x.to_string())?;
969                changes.push((
970                    e.kind.into(),
971                    e.key.clone(),
972                    Some(rev.clone()),
973                    Some(e.rev.clone()),
974                ));
975                dirty = true;
976            }
977            Some((rev, old_pos)) => {
978                if *old_pos != pos {
979                    xor(&mut acc, &row_hash(e.kind, &e.key, *old_pos, rev));
980                    xor(&mut acc, &row_hash(e.kind, &e.key, pos, rev));
981                    conn.execute(
982                        "UPDATE vault_rows SET pos = ?3 WHERE kind = ?1 AND key = ?2",
983                        params![e.kind, e.key, pos],
984                    )
985                    .map_err(|x| x.to_string())?;
986                    dirty = true;
987                }
988            }
989        }
990        if e.kind == KIND_ENTRY {
991            if let Some(h) = e
992                .history
993                .as_ref()
994                .filter(|h| h.as_array().map(|a| !a.is_empty()).unwrap_or(false))
995            {
996                keep_hist.insert(e.key.clone());
997                let hrev = rev_of(h);
998                if stored_hist.get(&e.key) != Some(&hrev) {
999                    conn.execute(
1000                        "INSERT OR REPLACE INTO vault_history (key, data, rev) VALUES (?1, ?2, ?3)",
1001                        params![e.key, h.to_string(), hrev],
1002                    )
1003                    .map_err(|x| x.to_string())?;
1004                    dirty = true;
1005                }
1006            }
1007        }
1008    }
1009    for (id, (rev, old_pos)) in stored {
1010        if !keep.contains(id) {
1011            xor(&mut acc, &row_hash(&id.0, &id.1, *old_pos, rev));
1012            conn.execute(
1013                "DELETE FROM vault_rows WHERE kind = ?1 AND key = ?2",
1014                params![id.0, id.1],
1015            )
1016            .map_err(|x| x.to_string())?;
1017            changes.push((id.0.clone(), id.1.clone(), Some(rev.clone()), None));
1018            dirty = true;
1019        }
1020    }
1021    for k in stored_hist.keys() {
1022        if !keep_hist.contains(k) {
1023            conn.execute("DELETE FROM vault_history WHERE key = ?1", params![k])
1024                .map_err(|x| x.to_string())?;
1025            dirty = true;
1026        }
1027    }
1028
1029    if !dirty {
1030        if let Some(v) = version(conn)? {
1031            return Ok(v);
1032        }
1033    }
1034    let hash = acc_hex(&acc);
1035    conn.execute(
1036        "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('state_acc', ?1)",
1037        params![hash],
1038    )
1039    .map_err(|e| e.to_string())?;
1040    conn.execute(
1041        "INSERT INTO vault_saves (state_hash, at) VALUES (?1, ?2)",
1042        params![hash, now],
1043    )
1044    .map_err(|e| e.to_string())?;
1045    let seq = conn.last_insert_rowid();
1046    for (k, key, prev, new) in changes {
1047        conn.execute(
1048            "INSERT INTO vault_changes (seq, kind, key, prev_rev, new_rev) VALUES (?1, ?2, ?3, ?4, ?5)",
1049            params![seq, k, key, prev, new],
1050        )
1051        .map_err(|e| e.to_string())?;
1052    }
1053    prune_saves(conn, seq, now, KEEP_SAVES, HARD_KEEP_SAVES)?;
1054    let tok = token(seq, &hash);
1055    conn.execute(
1056        "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('data_hash', ?1)",
1057        params![tok],
1058    )
1059    .map_err(|e| e.to_string())?;
1060    Ok(tok)
1061}
1062
1063// ── Migration from v1 ────────────────────────────────────────────────────────
1064
1065fn legacy_blob(conn: &Connection) -> Result<Option<String>, String> {
1066    conn.query_row("SELECT data FROM vault WHERE id = 1", [], |r| r.get(0))
1067        .optional()
1068        .map_err(|e| e.to_string())
1069}
1070
1071/// True when a v1 blob is waiting to be converted.
1072pub fn needs_migration(conn: &Connection) -> Result<bool, String> {
1073    Ok(legacy_blob(conn)?.is_some())
1074}
1075
1076/// Copy `vault.db` to `vault.db.v1.bak` (owner-only) before the one-way conversion.
1077fn backup_v1(conn: &Connection) {
1078    let Some(path) = conn.path().map(std::path::PathBuf::from) else {
1079        return;
1080    };
1081    if path.as_os_str().is_empty() {
1082        return;
1083    }
1084    let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1085    let mut bak = path.as_os_str().to_owned();
1086    bak.push(".v1.bak");
1087    let bak = std::path::PathBuf::from(bak);
1088    if !bak.exists() && std::fs::copy(&path, &bak).is_ok() {
1089        let _ = crate::restrict_to_owner(&bak);
1090    }
1091}
1092
1093/// Convert the v1 blob into rows. Must run inside a write transaction. No audit
1094/// rows are written: nothing about the data changed, only where it lives.
1095pub fn migrate_in_txn(conn: &Connection, now: &str) -> Result<bool, String> {
1096    let Some(raw) = legacy_blob(conn)? else {
1097        return Ok(false);
1098    };
1099    let doc: Value =
1100        serde_json::from_str(&raw).map_err(|e| format!("legacy vault is unreadable: {e}"))?;
1101    conn.execute("DELETE FROM vault_rows", [])
1102        .map_err(|e| e.to_string())?;
1103    conn.execute("DELETE FROM vault_history", [])
1104        .map_err(|e| e.to_string())?;
1105    conn.execute("DELETE FROM vault_meta WHERE key = 'state_acc'", [])
1106        .map_err(|e| e.to_string())?;
1107    write(conn, &snapshot(conn)?, &split(doc), now)?;
1108    conn.execute("DELETE FROM vault WHERE id = 1", [])
1109        .map_err(|e| e.to_string())?;
1110    Ok(true)
1111}
1112
1113/// Migrate on its own transaction (the read path). Cheap when nothing is pending.
1114pub fn migrate_if_needed(conn: &Connection, now: &str) -> Result<(), String> {
1115    if !needs_migration(conn)? {
1116        return Ok(());
1117    }
1118    backup_v1(conn);
1119    conn.execute_batch("BEGIN IMMEDIATE")
1120        .map_err(|e| e.to_string())?;
1121    let r = migrate_in_txn(conn, now).and_then(|_| {
1122        conn.execute(
1123            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
1124            params![crate::VAULT_SCHEMA_VERSION.to_string()],
1125        )
1126        .map(|_| ())
1127        .map_err(|e| e.to_string())
1128    });
1129    match r {
1130        Ok(()) => conn.execute_batch("COMMIT").map_err(|e| e.to_string()),
1131        Err(e) => {
1132            let _ = conn.execute_batch("ROLLBACK");
1133            Err(e)
1134        }
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use crate::{load_vault, save_vault, vault_version, SaveCtx, CONFLICT_ERR};
1142    use serde_json::json;
1143
1144    fn open(tag: &str) -> (Connection, std::path::PathBuf) {
1145        let nanos = std::time::SystemTime::now()
1146            .duration_since(std::time::UNIX_EPOCH)
1147            .unwrap()
1148            .as_nanos();
1149        let dir =
1150            std::env::temp_dir().join(format!("vc-storage-{tag}-{}-{nanos}", std::process::id()));
1151        std::fs::create_dir_all(&dir).unwrap();
1152        let key = crate::derive_key("correct horse battery staple", b"0123456789abcdef").unwrap();
1153        let conn = crate::open_db(&dir.join("vault.db"), &key).unwrap();
1154        crate::init_schema(&conn).unwrap();
1155        (conn, dir)
1156    }
1157
1158    fn e(id: &str, provider: &str) -> Value {
1159        json!({ "id": id, "provider": provider, "api_key": format!("key-{id}") })
1160    }
1161
1162    fn save(conn: &Connection, doc: Value, base: Option<&str>) -> Result<String, String> {
1163        save_vault(
1164            conn,
1165            doc,
1166            SaveCtx {
1167                actor: None,
1168                expect_version: base,
1169            },
1170        )
1171    }
1172
1173    fn names(conn: &Connection) -> Vec<String> {
1174        load_vault(conn).unwrap().unwrap()["api_keys"]
1175            .as_array()
1176            .unwrap()
1177            .iter()
1178            .map(|x| x["provider"].as_str().unwrap().to_string())
1179            .collect()
1180    }
1181
1182    #[test]
1183    fn the_document_round_trips_including_unknown_top_level_keys_and_order() {
1184        let (conn, _d) = open("roundtrip");
1185        let doc = json!({
1186            "api_keys": [e("b", "B"), e("a", "A"), e("c", "C")],
1187            "projects": [{ "id": "p2", "name": "Two" }, { "id": "p1", "name": "One" }],
1188            "user_categories": ["x", "a/b"],
1189            "someFutureKey": { "nested": [1, 2, 3] }
1190        });
1191        save(&conn, doc.clone(), None).unwrap();
1192        let got = load_vault(&conn).unwrap().unwrap();
1193        assert_eq!(got, doc);
1194    }
1195
1196    #[test]
1197    fn a_save_writes_only_the_rows_that_changed() {
1198        let (conn, _d) = open("minimal");
1199        let v1 = save(
1200            &conn,
1201            json!({ "api_keys": [e("1", "A"), e("2", "B"), e("3", "C")] }),
1202            None,
1203        )
1204        .unwrap();
1205        save(
1206            &conn,
1207            json!({ "api_keys": [e("1", "A"), e("2", "B-edited"), e("3", "C")] }),
1208            Some(&v1),
1209        )
1210        .unwrap();
1211        let last: i64 = conn
1212            .query_row("SELECT MAX(seq) FROM vault_saves", [], |r| r.get(0))
1213            .unwrap();
1214        let changed: Vec<(String, String)> = conn
1215            .prepare("SELECT kind, key FROM vault_changes WHERE seq = ?1")
1216            .unwrap()
1217            .query_map(params![last], |r| Ok((r.get(0)?, r.get(1)?)))
1218            .unwrap()
1219            .map(|x| x.unwrap())
1220            .collect();
1221        assert_eq!(changed, vec![("entry".to_string(), "id\u{1}2".to_string())]);
1222    }
1223
1224    #[test]
1225    fn an_identical_save_creates_no_new_version() {
1226        let (conn, _d) = open("noop");
1227        let doc = json!({ "api_keys": [e("1", "A")] });
1228        let v1 = save(&conn, doc.clone(), None).unwrap();
1229        let v2 = save(&conn, doc, Some(&v1)).unwrap();
1230        assert_eq!(v1, v2);
1231        let n: i64 = conn
1232            .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1233            .unwrap();
1234        assert_eq!(n, 1);
1235    }
1236
1237    #[test]
1238    fn two_writers_editing_different_entries_both_land() {
1239        let (conn, _d) = open("disjoint");
1240        let base = json!({ "api_keys": [e("1", "A"), e("2", "B")] });
1241        let v1 = save(&conn, base, None).unwrap();
1242        // Writer one edits entry 1 and saves.
1243        save(
1244            &conn,
1245            json!({ "api_keys": [e("1", "A-one"), e("2", "B")] }),
1246            Some(&v1),
1247        )
1248        .unwrap();
1249        // Writer two, still holding v1, edits entry 2. Their copy of entry 1 is stale.
1250        let out = save(
1251            &conn,
1252            json!({ "api_keys": [e("1", "A"), e("2", "B-two")] }),
1253            Some(&v1),
1254        );
1255        assert!(out.is_ok(), "{out:?}");
1256        assert_eq!(names(&conn), vec!["A-one", "B-two"]);
1257    }
1258
1259    fn proj(id: &str, chunks: Value) -> Value {
1260        json!({ "id": id, "name": id, "chunks": chunks })
1261    }
1262    fn ch(id: &str, v: &str) -> Value {
1263        json!({ "id": id, "name": id, "value": v })
1264    }
1265    fn chunk_values(conn: &Connection, p: usize) -> Vec<String> {
1266        load_vault(conn).unwrap().unwrap()["projects"][p]["chunks"]
1267            .as_array()
1268            .unwrap()
1269            .iter()
1270            .map(|c| c["value"].as_str().unwrap().to_string())
1271            .collect()
1272    }
1273
1274    #[test]
1275    fn chunks_are_rows_of_their_own_and_the_document_round_trips() {
1276        let (conn, _d) = open("chunk-rows");
1277        let doc = json!({
1278            "api_keys": [],
1279            "projects": [
1280                proj("p1", json!([ch("c1", "a"), ch("c2", "b")])),
1281                proj("p2", json!([])),
1282                { "id": "p3", "name": "no chunks key" }
1283            ]
1284        });
1285        save(&conn, doc.clone(), None).unwrap();
1286        let rows: i64 = conn
1287            .query_row(
1288                "SELECT COUNT(*) FROM vault_rows WHERE kind='chunk'",
1289                [],
1290                |r| r.get(0),
1291            )
1292            .unwrap();
1293        assert_eq!(rows, 2);
1294        let got = load_vault(&conn).unwrap().unwrap();
1295        assert_eq!(got["projects"], doc["projects"]);
1296    }
1297
1298    #[test]
1299    fn inline_chunks_from_an_unconverted_vault_still_load() {
1300        let doc = json!({ "api_keys": [], "projects": [proj("p1", json!([ch("c1", "a")]))] });
1301        let ents = split(doc.clone());
1302        // Rebuild what a v2 build stored: the chunks inside the project row.
1303        let mut legacy = ents
1304            .into_iter()
1305            .filter(|e| e.kind != KIND_CHUNK)
1306            .collect::<Vec<_>>();
1307        for e in legacy.iter_mut().filter(|e| e.kind == KIND_PROJECT) {
1308            e.data["chunks"] = json!([ch("c1", "a")]);
1309        }
1310        assert_eq!(join(legacy)["projects"], doc["projects"]);
1311    }
1312
1313    #[test]
1314    fn two_writers_editing_different_chunks_of_one_project_both_land() {
1315        let (conn, _d) = open("chunk-disjoint");
1316        let base = json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "b")]))] });
1317        let v1 = save(&conn, base, None).unwrap();
1318        save(
1319            &conn,
1320            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a-one"), ch("c2", "b")]))] }),
1321            Some(&v1),
1322        )
1323        .unwrap();
1324        let out = save(
1325            &conn,
1326            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "b-two")]))] }),
1327            Some(&v1),
1328        );
1329        assert!(out.is_ok(), "{out:?}");
1330        assert_eq!(chunk_values(&conn, 0), vec!["a-one", "b-two"]);
1331    }
1332
1333    #[test]
1334    fn two_writers_editing_the_same_chunk_conflict_and_name_it() {
1335        let (conn, _d) = open("chunk-conflict");
1336        let base = json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a")]))] });
1337        let v1 = save(&conn, base, None).unwrap();
1338        save(
1339            &conn,
1340            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "one")]))] }),
1341            Some(&v1),
1342        )
1343        .unwrap();
1344        let err = save(
1345            &conn,
1346            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "two")]))] }),
1347            Some(&v1),
1348        )
1349        .unwrap_err();
1350        assert!(err.starts_with(CONFLICT_ERR) && err.contains("c1"), "{err}");
1351    }
1352
1353    #[test]
1354    fn deleting_a_project_removes_its_chunk_rows_and_a_stale_chunk_add_is_dropped() {
1355        let (conn, _d) = open("chunk-orphan");
1356        let v1 = save(
1357            &conn,
1358            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a")]))] }),
1359            None,
1360        )
1361        .unwrap();
1362        // Someone deletes the project; the stale writer adds a chunk to it.
1363        save(&conn, json!({ "api_keys": [], "projects": [] }), Some(&v1)).unwrap();
1364        save(
1365            &conn,
1366            json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "new")]))] }),
1367            Some(&v1),
1368        )
1369        .unwrap();
1370        let rows: i64 = conn
1371            .query_row(
1372                "SELECT COUNT(*) FROM vault_rows WHERE kind='chunk'",
1373                [],
1374                |r| r.get(0),
1375            )
1376            .unwrap();
1377        assert_eq!(rows, 0, "no orphan chunk rows");
1378        assert!(verify(&conn).unwrap());
1379    }
1380
1381    #[test]
1382    fn change_history_is_kept_by_age_as_well_as_by_count() {
1383        let (conn, _d) = open("retention");
1384        for i in 0..12 {
1385            save(
1386                &conn,
1387                json!({ "api_keys": [e("1", &format!("v{i}"))] }),
1388                None,
1389            )
1390            .unwrap();
1391        }
1392        // Age the first six saves to a year ago; the rest are "now".
1393        conn.execute(
1394            "UPDATE vault_saves SET at = '2000-01-01T00:00:00Z' WHERE seq <= 6",
1395            [],
1396        )
1397        .unwrap();
1398        let max: i64 = conn
1399            .query_row("SELECT MAX(seq) FROM vault_saves", [], |r| r.get(0))
1400            .unwrap();
1401        let now = crate::iso_now();
1402        // keep=4: old AND beyond the newest four goes; the recent ones stay even beyond four.
1403        prune_saves(&conn, max, &now, 4, 1000).unwrap();
1404        let left: i64 = conn
1405            .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1406            .unwrap();
1407        assert_eq!(left, 6, "six old saves dropped, six recent kept");
1408        let low: i64 = conn
1409            .query_row(
1410                "SELECT COUNT(*) FROM vault_changes WHERE seq <= 6",
1411                [],
1412                |r| r.get(0),
1413            )
1414            .unwrap();
1415        assert_eq!(low, 0, "their change rows go with them");
1416        // The hard cap beats age.
1417        prune_saves(&conn, max, &now, 4, 3).unwrap();
1418        let left: i64 = conn
1419            .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1420            .unwrap();
1421        assert_eq!(left, 3);
1422        assert_eq!(merge_window(&conn).unwrap().unwrap().1, 3);
1423    }
1424
1425    #[test]
1426    fn one_entrys_history_can_be_read_alone() {
1427        let (conn, _d) = open("hist-one");
1428        let mut a = e("a", "A");
1429        a["version_history"] = json!([{ "value": "old", "saved_at": "2026-01-01T00:00:00Z" }]);
1430        save(&conn, json!({ "api_keys": [a, e("b", "B")] }), None).unwrap();
1431        assert_eq!(
1432            entry_history(&conn, "a").unwrap().unwrap()[0]["value"],
1433            "old"
1434        );
1435        assert_eq!(entry_history(&conn, "b").unwrap().unwrap(), json!([]));
1436        assert!(entry_history(&conn, "missing").unwrap().is_none());
1437    }
1438
1439    #[test]
1440    fn a_stale_writer_neither_deletes_nor_overwrites_what_it_never_saw() {
1441        let (conn, _d) = open("unseen");
1442        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1443        // Someone else adds entry 2.
1444        save(
1445            &conn,
1446            json!({ "api_keys": [e("1", "A"), e("2", "Added")] }),
1447            Some(&v1),
1448        )
1449        .unwrap();
1450        // The stale writer edits entry 1 and does not have entry 2.
1451        save(&conn, json!({ "api_keys": [e("1", "A-edit")] }), Some(&v1)).unwrap();
1452        assert_eq!(names(&conn), vec!["A-edit", "Added"]);
1453    }
1454
1455    #[test]
1456    fn deleting_an_untouched_entry_while_another_is_added_applies_both() {
1457        let (conn, _d) = open("delete-add");
1458        let v1 = save(
1459            &conn,
1460            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1461            None,
1462        )
1463        .unwrap();
1464        save(
1465            &conn,
1466            json!({ "api_keys": [e("1", "A"), e("2", "B"), e("3", "C")] }),
1467            Some(&v1),
1468        )
1469        .unwrap();
1470        save(&conn, json!({ "api_keys": [e("1", "A")] }), Some(&v1)).unwrap();
1471        assert_eq!(names(&conn), vec!["A", "C"]);
1472    }
1473
1474    #[test]
1475    fn a_merged_save_says_so_and_a_writer_that_reloads_carries_on() {
1476        let (conn, _d) = open("merged-token");
1477        let v1 = save(
1478            &conn,
1479            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1480            None,
1481        )
1482        .unwrap();
1483        // Someone else edits entry 1.
1484        save(
1485            &conn,
1486            json!({ "api_keys": [e("1", "A-theirs"), e("2", "B")] }),
1487            Some(&v1),
1488        )
1489        .unwrap();
1490        // The stale writer edits entry 2; entry 1 in its copy is behind.
1491        let t = save(
1492            &conn,
1493            json!({ "api_keys": [e("1", "A"), e("2", "B-mine")] }),
1494            Some(&v1),
1495        )
1496        .unwrap();
1497        let bare = t
1498            .strip_suffix(crate::MERGED_SUFFIX)
1499            .expect("a merge is marked");
1500        assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(bare));
1501        // Reloading gives the merged document, and its token is a valid base.
1502        assert_eq!(names(&conn), vec!["A-theirs", "B-mine"]);
1503        let mut doc = load_vault(&conn).unwrap().unwrap();
1504        doc["api_keys"][1]["provider"] = json!("B-mine-2");
1505        save(&conn, doc, Some(bare)).unwrap();
1506        assert_eq!(names(&conn), vec!["A-theirs", "B-mine-2"]);
1507    }
1508
1509    #[test]
1510    fn the_marked_token_is_accepted_as_a_base() {
1511        let (conn, _d) = open("marked");
1512        let v1 = save(
1513            &conn,
1514            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1515            None,
1516        )
1517        .unwrap();
1518        save(
1519            &conn,
1520            json!({ "api_keys": [e("1", "A-theirs"), e("2", "B")] }),
1521            Some(&v1),
1522        )
1523        .unwrap();
1524        let t = save(
1525            &conn,
1526            json!({ "api_keys": [e("1", "A"), e("2", "B-mine")] }),
1527            Some(&v1),
1528        )
1529        .unwrap();
1530        assert!(t.ends_with(crate::MERGED_SUFFIX));
1531        let mut doc = load_vault(&conn).unwrap().unwrap();
1532        doc["api_keys"][0]["api_key"] = json!("changed");
1533        assert!(save(&conn, doc, Some(&t)).is_ok());
1534    }
1535
1536    #[test]
1537    fn a_save_with_nothing_to_merge_returns_the_new_version() {
1538        let (conn, _d) = open("clean-token");
1539        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1540        let v2 = save(&conn, json!({ "api_keys": [e("1", "A2")] }), Some(&v1)).unwrap();
1541        assert_ne!(v1, v2);
1542        assert!(!v2.contains('+'), "no merge happened: {v2}");
1543        assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(v2.as_str()));
1544    }
1545
1546    #[test]
1547    fn deleting_an_entry_someone_else_edited_is_a_conflict_that_names_it() {
1548        let (conn, _d) = open("delete-edited");
1549        let v1 = save(
1550            &conn,
1551            json!({ "api_keys": [e("1", "Keep"), e("2", "Contested")] }),
1552            None,
1553        )
1554        .unwrap();
1555        save(
1556            &conn,
1557            json!({ "api_keys": [e("1", "Keep"), e("2", "Contested-edited")] }),
1558            Some(&v1),
1559        )
1560        .unwrap();
1561        let err = save(&conn, json!({ "api_keys": [e("1", "Keep")] }), Some(&v1)).unwrap_err();
1562        assert!(err.starts_with(CONFLICT_ERR));
1563        assert!(err.contains("Contested-edited"), "{err}");
1564        assert_eq!(names(&conn), vec!["Keep", "Contested-edited"]);
1565    }
1566
1567    #[test]
1568    fn editing_an_entry_someone_else_deleted_is_a_conflict() {
1569        let (conn, _d) = open("edit-deleted");
1570        let v1 = save(
1571            &conn,
1572            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1573            None,
1574        )
1575        .unwrap();
1576        save(&conn, json!({ "api_keys": [e("1", "A")] }), Some(&v1)).unwrap();
1577        let err = save(
1578            &conn,
1579            json!({ "api_keys": [e("1", "A"), e("2", "B-edit")] }),
1580            Some(&v1),
1581        )
1582        .unwrap_err();
1583        assert!(err.starts_with(CONFLICT_ERR), "{err}");
1584    }
1585
1586    #[test]
1587    fn two_writers_arriving_at_the_same_content_do_not_conflict() {
1588        let (conn, _d) = open("same");
1589        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1590        save(&conn, json!({ "api_keys": [e("1", "Same")] }), Some(&v1)).unwrap();
1591        assert!(save(&conn, json!({ "api_keys": [e("1", "Same")] }), Some(&v1)).is_ok());
1592    }
1593
1594    #[test]
1595    fn projects_and_categories_merge_like_entries() {
1596        let (conn, _d) = open("projects");
1597        let v1 = save(
1598            &conn,
1599            json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One" }, { "id": "p2", "name": "Two" }], "user_categories": ["a"] }),
1600            None,
1601        )
1602        .unwrap();
1603        save(
1604            &conn,
1605            json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One-renamed" }, { "id": "p2", "name": "Two" }], "user_categories": ["a"] }),
1606            Some(&v1),
1607        )
1608        .unwrap();
1609        save(
1610            &conn,
1611            json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One" }, { "id": "p2", "name": "Two-renamed" }], "user_categories": ["a", "b"] }),
1612            Some(&v1),
1613        )
1614        .unwrap();
1615        let got = load_vault(&conn).unwrap().unwrap();
1616        assert_eq!(got["projects"][0]["name"], "One-renamed");
1617        assert_eq!(got["projects"][1]["name"], "Two-renamed");
1618        assert_eq!(got["user_categories"], json!(["a", "b"]));
1619    }
1620
1621    #[test]
1622    fn a_token_in_the_old_form_or_from_a_pruned_save_is_a_whole_vault_conflict() {
1623        let (conn, _d) = open("oldtoken");
1624        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1625        save(&conn, json!({ "api_keys": [e("1", "A2")] }), Some(&v1)).unwrap();
1626        for bad in [
1627            "deadbeef".to_string(),
1628            "1.0000".to_string(),
1629            "x.y".to_string(),
1630        ] {
1631            let err = save(&conn, json!({ "api_keys": [e("9", "Z")] }), Some(&bad)).unwrap_err();
1632            assert!(err.starts_with(CONFLICT_ERR), "{bad}: {err}");
1633        }
1634        // The base save has aged out of the retained history.
1635        conn.execute("DELETE FROM vault_saves WHERE seq = 1", [])
1636            .unwrap();
1637        let err = save(&conn, json!({ "api_keys": [e("1", "mine")] }), Some(&v1)).unwrap_err();
1638        assert!(err.starts_with(CONFLICT_ERR), "{err}");
1639    }
1640
1641    #[test]
1642    fn history_lives_in_its_own_table_and_not_in_the_row() {
1643        let (conn, _d) = open("history");
1644        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1645        let mut changed = e("1", "A");
1646        changed["api_key"] = json!("rotated");
1647        save(&conn, json!({ "api_keys": [changed] }), Some(&v1)).unwrap();
1648        let row: String = conn
1649            .query_row(
1650                "SELECT data FROM vault_rows WHERE kind = 'entry'",
1651                [],
1652                |r| r.get(0),
1653            )
1654            .unwrap();
1655        assert!(!row.contains("version_history"), "{row}");
1656        assert!(
1657            !row.contains("key-1"),
1658            "the old key belongs in the history table only"
1659        );
1660        let hist: String = conn
1661            .query_row("SELECT data FROM vault_history", [], |r| r.get(0))
1662            .unwrap();
1663        assert!(hist.contains("key-1"));
1664        // And it comes back attached to the entry.
1665        let doc = load_vault(&conn).unwrap().unwrap();
1666        assert_eq!(doc["api_keys"][0]["version_history"][0]["value"], "key-1");
1667    }
1668
1669    #[test]
1670    fn deleting_an_entry_deletes_its_history() {
1671        let (conn, _d) = open("history-delete");
1672        let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1673        let mut changed = e("1", "A");
1674        changed["api_key"] = json!("rotated");
1675        let v2 = save(&conn, json!({ "api_keys": [changed] }), Some(&v1)).unwrap();
1676        save(&conn, json!({ "api_keys": [] }), Some(&v2)).unwrap();
1677        let n: i64 = conn
1678            .query_row("SELECT COUNT(*) FROM vault_history", [], |r| r.get(0))
1679            .unwrap();
1680        assert_eq!(n, 0);
1681    }
1682
1683    #[test]
1684    fn legacy_entries_with_the_same_identity_both_survive() {
1685        let (conn, _d) = open("dupes");
1686        let doc = json!({ "api_keys": [
1687            { "provider": "Same", "api_key": "one" },
1688            { "provider": "Same", "api_key": "two" }
1689        ] });
1690        save(&conn, doc, None).unwrap();
1691        let got = load_vault(&conn).unwrap().unwrap();
1692        let keys: Vec<&str> = got["api_keys"]
1693            .as_array()
1694            .unwrap()
1695            .iter()
1696            .map(|x| x["api_key"].as_str().unwrap())
1697            .collect();
1698        assert_eq!(keys, vec!["one", "two"]);
1699    }
1700
1701    #[test]
1702    fn reordering_is_a_change_but_not_a_content_conflict() {
1703        let (conn, _d) = open("reorder");
1704        let v1 = save(
1705            &conn,
1706            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1707            None,
1708        )
1709        .unwrap();
1710        let v2 = save(
1711            &conn,
1712            json!({ "api_keys": [e("2", "B"), e("1", "A")] }),
1713            Some(&v1),
1714        )
1715        .unwrap();
1716        assert_ne!(v1, v2);
1717        assert_eq!(names(&conn), vec!["B", "A"]);
1718    }
1719
1720    #[test]
1721    fn tampering_with_a_row_or_with_the_token_fails_integrity() {
1722        let (conn, _d) = open("verify");
1723        save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1724        assert!(crate::verify_vault_integrity(&conn).unwrap());
1725        conn.execute("UPDATE vault_rows SET pos = 7 WHERE kind = 'entry'", [])
1726            .unwrap();
1727        assert!(
1728            !crate::verify_vault_integrity(&conn).unwrap(),
1729            "a moved row changes the state hash"
1730        );
1731    }
1732
1733    // ── v1 -> v2 ───────────────────────────────────────────────────────────────
1734
1735    fn plant_v1(conn: &Connection, doc: &Value) {
1736        conn.execute(
1737            "INSERT OR REPLACE INTO vault (id, data) VALUES (1, ?1)",
1738            params![doc.to_string()],
1739        )
1740        .unwrap();
1741        conn.execute(
1742            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1743            [],
1744        )
1745        .unwrap();
1746        conn.execute(
1747            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('data_hash', 'abc')",
1748            [],
1749        )
1750        .unwrap();
1751    }
1752
1753    #[test]
1754    fn a_v1_vault_is_converted_on_first_open_with_a_backup_and_nothing_lost() {
1755        let (conn, dir) = open("migrate");
1756        let doc = json!({
1757            "api_keys": [
1758                { "id": "1", "provider": "A", "api_key": "k", "version_history": [{ "value": "old", "saved_at": "2026-01-01T00:00:00Z" }] },
1759                { "id": "2", "provider": "B", "api_key": "k2" }
1760            ],
1761            "projects": [{ "id": "p", "name": "P", "chunks": [] }],
1762            "user_categories": ["c"],
1763            "extra": 1
1764        });
1765        plant_v1(&conn, &doc);
1766        let audit_before = crate::load_audit(&conn).unwrap().len();
1767
1768        let got = load_vault(&conn).unwrap().unwrap();
1769        assert_eq!(got, doc, "same document, new storage");
1770        assert_eq!(
1771            crate::vault_schema_version(&conn).unwrap(),
1772            Some(crate::VAULT_SCHEMA_VERSION)
1773        );
1774        assert!(
1775            dir.join("vault.db.v1.bak").exists(),
1776            "the one-way conversion is backed up first"
1777        );
1778        let blob: i64 = conn
1779            .query_row("SELECT COUNT(*) FROM vault", [], |r| r.get(0))
1780            .unwrap();
1781        assert_eq!(blob, 0, "no second copy of every secret is left behind");
1782        assert_eq!(
1783            crate::load_audit(&conn).unwrap().len(),
1784            audit_before,
1785            "a move is not an edit"
1786        );
1787        assert!(crate::verify_vault_integrity(&conn).unwrap());
1788        // The old history is where history now lives.
1789        let h: i64 = conn
1790            .query_row("SELECT COUNT(*) FROM vault_history", [], |r| r.get(0))
1791            .unwrap();
1792        assert_eq!(h, 1);
1793        // Idempotent.
1794        assert_eq!(load_vault(&conn).unwrap().unwrap(), doc);
1795    }
1796
1797    #[test]
1798    fn the_version_read_before_the_data_is_valid_after_a_conversion() {
1799        let (conn, _d) = open("v1-version-order");
1800        plant_v1(&conn, &json!({ "api_keys": [e("1", "A")] }));
1801        crate::ensure_current_schema(&conn).unwrap();
1802        let v = vault_version(&conn).unwrap().unwrap();
1803        let doc = load_vault(&conn).unwrap().unwrap();
1804        let mut edited = doc;
1805        edited["api_keys"][0]["provider"] = json!("A2");
1806        assert!(
1807            save(&conn, edited, Some(&v)).is_ok(),
1808            "the first save after an upgrade must not conflict"
1809        );
1810    }
1811
1812    #[test]
1813    fn saving_a_v1_vault_converts_it_in_the_same_transaction() {
1814        let (conn, _d) = open("migrate-save");
1815        plant_v1(&conn, &json!({ "api_keys": [e("1", "A")] }));
1816        let v = vault_version(&conn).unwrap().unwrap();
1817        assert_eq!(
1818            v, "abc",
1819            "before conversion the old token is still what is stored"
1820        );
1821        let out = save(
1822            &conn,
1823            json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1824            None,
1825        )
1826        .unwrap();
1827        assert_ne!(out, "abc");
1828        assert_eq!(names(&conn), vec!["A", "B"]);
1829        let blob: i64 = conn
1830            .query_row("SELECT COUNT(*) FROM vault", [], |r| r.get(0))
1831            .unwrap();
1832        assert_eq!(blob, 0);
1833    }
1834
1835    #[test]
1836    fn a_vault_with_rows_but_a_v1_stamp_and_no_blob_is_left_alone() {
1837        let (conn, _d) = open("stamp-only");
1838        save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1839        conn.execute(
1840            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1841            [],
1842        )
1843        .unwrap();
1844        assert_eq!(names(&conn), vec!["A"]);
1845    }
1846
1847    /// Not a test: a measurement, run with `--ignored --nocapture`. Nothing here
1848    /// should be optimised before it is instrumented, and the claim behind schema
1849    /// v2 is that one edit costs O(1) rows instead of O(vault).
1850    #[test]
1851    #[ignore]
1852    fn measure_one_edit_against_the_blob() {
1853        use std::time::Instant;
1854        let (conn, _d) = open("bench");
1855        let n = 5000;
1856        let entries: Vec<Value> = (0..n)
1857            .map(|i| {
1858                json!({
1859                    "id": format!("id-{i}"), "provider": format!("Provider {i}"),
1860                    "api_key": "x".repeat(48), "description": "d".repeat(200),
1861                    "tags": ["a", "b"], "extra_vars": [{"key": "K", "value": "v".repeat(64)}],
1862                    "version_history": (0..3).map(|j| json!({"value": "o".repeat(48), "saved_at": format!("2026-01-0{}T00:00:00Z", j + 1)})).collect::<Vec<_>>()
1863                })
1864            })
1865            .collect();
1866        let mut doc = json!({ "api_keys": entries, "projects": [], "user_categories": [] });
1867        let t = Instant::now();
1868        let mut base = save(&conn, doc.clone(), None).unwrap();
1869        println!("first save of {n} entries: {:?}", t.elapsed());
1870
1871        // One edit, v2.
1872        doc["api_keys"][2500]["description"] = json!("edited");
1873        let t = Instant::now();
1874        base = save(&conn, doc.clone(), Some(&base)).unwrap();
1875        let v2 = t.elapsed();
1876        println!("v2: one edit in {n}: {v2:?}");
1877        let t = Instant::now();
1878        let _parts = split(doc.clone());
1879        println!("  split: {:?}", t.elapsed());
1880        // What v1 did for the same edit: read the blob, parse it, index every entry
1881        // (cloning, as the old code did), serialise the new document, hash it and
1882        // rewrite the one row.
1883        conn.execute_batch(
1884            "CREATE TABLE IF NOT EXISTS old_blob (id INTEGER PRIMARY KEY, data TEXT)",
1885        )
1886        .unwrap();
1887        let first = serde_json::to_string(&doc).unwrap();
1888        conn.execute(
1889            "INSERT OR REPLACE INTO old_blob (id, data) VALUES (1, ?1)",
1890            params![first],
1891        )
1892        .unwrap();
1893        let t = Instant::now();
1894        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
1895        let old: String = conn
1896            .query_row("SELECT data FROM old_blob WHERE id = 1", [], |r| r.get(0))
1897            .unwrap();
1898        let old_doc: Value = serde_json::from_str(&old).unwrap();
1899        let old_map: HashMap<String, Value> = old_doc["api_keys"]
1900            .as_array()
1901            .unwrap()
1902            .iter()
1903            .map(|e| (crate::entry_ck(e), e.clone()))
1904            .collect();
1905        let _same = doc["api_keys"]
1906            .as_array()
1907            .unwrap()
1908            .iter()
1909            .filter(|e| old_map.contains_key(&crate::entry_ck(e)))
1910            .count();
1911        let raw = serde_json::to_string(&doc).unwrap();
1912        let _h = sha(&raw);
1913        conn.execute(
1914            "INSERT OR REPLACE INTO old_blob (id, data) VALUES (1, ?1)",
1915            params![raw],
1916        )
1917        .unwrap();
1918        conn.execute_batch("COMMIT").unwrap();
1919        let v1 = t.elapsed();
1920        println!(
1921            "v1 (read blob, parse, index, serialise, hash, rewrite; {} KB): {v1:?}",
1922            raw.len() / 1024
1923        );
1924
1925        let t = Instant::now();
1926        let loaded = load_vault(&conn).unwrap().unwrap();
1927        println!("load of {n} entries with history: {:?}", t.elapsed());
1928        let t = Instant::now();
1929        let lite = load_lite(&conn).unwrap().unwrap();
1930        println!("selective load without history: {:?}", t.elapsed());
1931        assert_eq!(
1932            loaded["api_keys"].as_array().unwrap().len(),
1933            lite["api_keys"].as_array().unwrap().len()
1934        );
1935        let _ = base;
1936    }
1937}