Skip to main content

agentmux_srv/backend/
memory_record.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! AgentMux's own record of an agent's memory
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.1, phase M3).
6//!
7//! An agent's memory files follow its provider account and working directory
8//! — `$CLAUDE_CONFIG_DIR/projects/<cwd>/memory` — so an account switch or a
9//! new working directory used to leave them behind. The record lives in the
10//! global transcript store, keyed by the agent's UID (`db_agents.id`), and is
11//! the source of truth the provider folder is reconciled against.
12//!
13//! Zone `agent-uid:<uid>:memory`:
14//! - `log.jsonl` — append-only events: a file version (a body by hash, or a
15//!   tombstone) or a projection (which version a directory holds);
16//! - `blob/<sha256>` — one file per distinct body, written before the log
17//!   line that names it, in the same transaction;
18//! - `heads.json` — per file, the head version; per directory, the version
19//!   last projected there. Rewritten with every append, so current state is
20//!   one small read and no tail window has to hold a rarely edited file.
21//!
22//! Every write is one `zone_txn` — a conditional append against the head the
23//! caller expects — so srv processes sharing the store never fork a file's
24//! chain. Nothing here touches the provider folder; reconciliation does
25//! (phase M3b).
26
27use std::collections::BTreeMap;
28
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32use crate::backend::storage::error::StoreError;
33use crate::backend::storage::filestore::FileStore;
34
35const LOG_FILE: &str = "log.jsonl";
36const HEADS_FILE: &str = "heads.json";
37/// Same cap as a memory file today (`app_api` memory write).
38pub(crate) const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
39
40/// `agent-uid:<uid>:memory`, or `None` for a UID outside the safe zone-name
41/// character set.
42pub(crate) fn zone_for(agent_uid: &str) -> Option<String> {
43    crate::backend::agent_session::is_valid_definition_id(agent_uid).then(|| format!("agent-uid:{agent_uid}:memory"))
44}
45
46pub(crate) fn sha256_hex(body: &[u8]) -> String {
47    hex::encode(Sha256::digest(body))
48}
49
50/// One line of `log.jsonl`.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "event", rename_all = "snake_case")]
53pub(crate) enum Event {
54    Version(Version),
55    /// `dir_id` now holds `version` of `file`. Local to this machine; never
56    /// synced.
57    Projected { dir_id: String, file: String, version: String, at_ms: i64 },
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub(crate) struct Version {
62    pub file: String,
63    pub version: String,
64    /// The version this one replaces; `None` for a file's first version.
65    pub parent: Option<String>,
66    /// A merge's second parent (spec §2.1.3).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub merged_parent: Option<String>,
69    /// The body's hash; `None` for a tombstone.
70    pub sha256: Option<String>,
71    /// Set when this version won a conflict against another head, kept in
72    /// the record and written beside it as a `__conflict_` file.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub conflicts_with: Option<String>,
75    pub source: String,
76    #[serde(default)]
77    pub source_detail: String,
78    pub created_at_ms: i64,
79}
80
81impl Version {
82    #[cfg(test)]
83    pub(crate) fn is_tombstone(&self) -> bool {
84        self.sha256.is_none()
85    }
86}
87
88/// A file's head.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub(crate) struct Head {
91    pub version: String,
92    /// The head's own parent, so a second capture of the same change from
93    /// the same parent is recognised as already recorded.
94    #[serde(default)]
95    pub parent: Option<String>,
96    pub sha256: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub conflicts_with: Option<String>,
99    /// Fields a newer build added, kept when this build rewrites the file.
100    #[serde(flatten)]
101    pub extra: serde_json::Map<String, serde_json::Value>,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
105pub(crate) struct Heads {
106    /// Head per file name.
107    #[serde(default)]
108    pub files: BTreeMap<String, Head>,
109    /// Per directory, per file: the version last projected there.
110    #[serde(default)]
111    pub projected: BTreeMap<String, BTreeMap<String, String>>,
112    /// When the agent's per-channel version history was imported
113    /// ([`import_history`]); `None` until then.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub imported_at_ms: Option<i64>,
116    /// Per directory, per file: content found there at first sighting that
117    /// another agent's record already holds — kept out of this record for
118    /// the adoption list (spec §2.1.2), and left alone while the file
119    /// still holds it.
120    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121    pub held: BTreeMap<String, BTreeMap<String, String>>,
122    /// `__conflict_` files MEMORY.md has been given a line for, so a line
123    /// someone removes on purpose isn't added back.
124    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
125    pub indexed_conflicts: std::collections::BTreeSet<String>,
126    /// Fields a newer build added, kept when this build rewrites the file.
127    #[serde(flatten)]
128    pub extra: serde_json::Map<String, serde_json::Value>,
129}
130
131impl Heads {
132    /// The files that currently exist (not tombstoned).
133    pub(crate) fn live(&self) -> impl Iterator<Item = (&String, &Head)> {
134        self.files.iter().filter(|(_, h)| h.sha256.is_some())
135    }
136}
137
138/// A version to append.
139pub(crate) struct NewVersion<'a> {
140    pub file: &'a str,
141    /// The new body; `None` records a deletion.
142    pub body: Option<&'a [u8]>,
143    /// The head the caller based this on (`None`: the file has no versions
144    /// yet). A different current head refuses the append.
145    pub expected_parent: Option<&'a str>,
146    pub merged_parent: Option<&'a str>,
147    pub conflicts_with: Option<&'a str>,
148    pub source: &'a str,
149    pub source_detail: &'a str,
150    /// Also record, in the same transaction, that this directory now holds
151    /// the new version (a capture from it, or a write through AgentMux to it).
152    pub project_to: Option<&'a str>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
156pub(crate) enum AppendOutcome {
157    Appended(Version),
158    /// This change is already recorded — the head has this content, either
159    /// as `expected_parent` itself or as its child (N srvs capturing the same
160    /// provider write record it once). Carries the head's version.
161    Unchanged(String),
162    /// Deleting a file the record never had: nothing to record.
163    NothingToRecord,
164    /// The file's head isn't `expected_parent`; re-read and decide again.
165    StaleParent { head: Option<String> },
166}
167
168/// A memory file name: one path component of safe characters.
169pub(crate) fn validate_file(file: &str) -> Result<(), StoreError> {
170    let ok = !file.is_empty()
171        && file.len() <= 255
172        && file != "."
173        && file != ".."
174        && file.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
175    if ok {
176        Ok(())
177    } else {
178        Err(StoreError::Other(format!("memory record: invalid file name {file:?}")))
179    }
180}
181
182fn validate_sha(sha256: &str) -> Result<(), StoreError> {
183    if sha256.len() == 64 && sha256.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
184        Ok(())
185    } else {
186        Err(StoreError::Other(format!("memory record: invalid hash {sha256:?}")))
187    }
188}
189
190/// `heads.json`, or empty when the record has none yet. A file that exists
191/// but doesn't parse is an error — never silently empty, or the next write
192/// would replace every head and projection with just its own.
193fn read_heads(bytes: Option<Vec<u8>>) -> Result<Heads, StoreError> {
194    match bytes {
195        None => Ok(Heads::default()),
196        Some(b) => serde_json::from_slice(&b)
197            .map_err(|e| StoreError::Other(format!("memory record: heads.json unreadable: {e}"))),
198    }
199}
200
201fn log_line(event: &Event) -> Result<Vec<u8>, StoreError> {
202    let mut line = serde_json::to_vec(event).map_err(|e| StoreError::Other(format!("memory record: encode: {e}")))?;
203    line.push(b'\n');
204    Ok(line)
205}
206
207fn zone_or_err(agent_uid: &str) -> Result<String, StoreError> {
208    zone_for(agent_uid).ok_or_else(|| StoreError::Other(format!("memory record: invalid agent uid {agent_uid:?}")))
209}
210
211/// The agent's current heads, read as one snapshot sized from the database.
212pub(crate) fn heads(fs: &FileStore, agent_uid: &str) -> Result<Heads, StoreError> {
213    let zone = zone_or_err(agent_uid)?;
214    let mut got = fs.read_files_consistent(&zone, &[HEADS_FILE])?;
215    read_heads(got.pop().flatten())
216}
217
218/// A body by hash.
219pub(crate) fn body(fs: &FileStore, agent_uid: &str, sha256: &str) -> Result<Option<Vec<u8>>, StoreError> {
220    validate_sha(sha256)?;
221    let zone = zone_or_err(agent_uid)?;
222    let mut got = fs.read_files_consistent(&zone, &[&format!("blob/{sha256}")])?;
223    Ok(got.pop().flatten())
224}
225
226/// Append a version if the file's head is still `expected_parent`.
227pub(crate) fn append_version(fs: &FileStore, agent_uid: &str, v: NewVersion<'_>) -> Result<AppendOutcome, StoreError> {
228    validate_file(v.file)?;
229    if let Some(b) = v.body {
230        if b.len() > MAX_BODY_BYTES {
231            return Err(StoreError::Other(format!(
232                "memory record: {} is {} bytes (max {MAX_BODY_BYTES})",
233                v.file,
234                b.len()
235            )));
236        }
237    }
238    let zone = zone_or_err(agent_uid)?;
239    let sha = v.body.map(sha256_hex);
240    fs.zone_txn(&zone, |z| {
241        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
242        let plain = v.merged_parent.is_none() && v.conflicts_with.is_none();
243        let head = heads.files.get(v.file);
244        let head_version = head.map(|h| h.version.as_str());
245        if let Some(h) = head {
246            // Already recorded: the head IS this content on this parent, or
247            // is its child from the same parent (another srv captured the
248            // same change first).
249            let same_change = h.sha256 == sha
250                && (head_version == v.expected_parent || h.parent.as_deref() == v.expected_parent);
251            if plain && same_change {
252                let version = h.version.clone();
253                if let Some(dir) = v.project_to {
254                    set_projected(z, &mut heads, dir, v.file, &version)?;
255                }
256                return Ok(AppendOutcome::Unchanged(version));
257            }
258        } else if sha.is_none() {
259            return Ok(AppendOutcome::NothingToRecord);
260        }
261        if head_version != v.expected_parent {
262            return Ok(AppendOutcome::StaleParent { head: head_version.map(str::to_string) });
263        }
264        if let (Some(sha), Some(body)) = (&sha, v.body) {
265            z.put_if_absent(&format!("blob/{sha}"), body)?;
266        }
267        let version = Version {
268            file: v.file.to_string(),
269            version: format!("v_{}", uuid::Uuid::new_v4().simple()),
270            parent: v.expected_parent.map(str::to_string),
271            merged_parent: v.merged_parent.map(str::to_string),
272            sha256: sha.clone(),
273            conflicts_with: v.conflicts_with.map(str::to_string),
274            source: v.source.to_string(),
275            source_detail: v.source_detail.to_string(),
276            created_at_ms: agentmux_common::time::now_ms(),
277        };
278        z.append_lines(LOG_FILE, &log_line(&Event::Version(version.clone()))?)?;
279        let extra = heads.files.get(v.file).map(|h| h.extra.clone()).unwrap_or_default();
280        heads.files.insert(
281            v.file.to_string(),
282            Head {
283                version: version.version.clone(),
284                parent: version.parent.clone(),
285                sha256: sha,
286                conflicts_with: version.conflicts_with.clone(),
287                extra,
288            },
289        );
290        if let Some(dir) = v.project_to {
291            set_projected(z, &mut heads, dir, v.file, &version.version)?;
292        }
293        write_heads(z, &heads)?;
294        Ok(AppendOutcome::Appended(version))
295    })
296}
297
298fn write_heads(z: &mut crate::backend::storage::filestore::ZoneTxn<'_>, heads: &Heads) -> Result<(), StoreError> {
299    z.put(HEADS_FILE, &serde_json::to_vec(heads).map_err(|e| StoreError::Other(e.to_string()))?)
300}
301
302/// Point `dir_id`'s projection of `file` at `version` and log it (the caller
303/// writes `heads.json`, or it is written here when nothing else changes).
304fn set_projected(
305    z: &mut crate::backend::storage::filestore::ZoneTxn<'_>,
306    heads: &mut Heads,
307    dir_id: &str,
308    file: &str,
309    version: &str,
310) -> Result<(), StoreError> {
311    let per_dir = heads.projected.entry(dir_id.to_string()).or_default();
312    if per_dir.get(file).map(String::as_str) == Some(version) {
313        return Ok(());
314    }
315    per_dir.insert(file.to_string(), version.to_string());
316    let event = Event::Projected {
317        dir_id: dir_id.to_string(),
318        file: file.to_string(),
319        version: version.to_string(),
320        at_ms: agentmux_common::time::now_ms(),
321    };
322    z.append_lines(LOG_FILE, &log_line(&event)?)?;
323    write_heads(z, heads)
324}
325
326/// Record that `dir_id` now holds `version` of `file`, if its projection is
327/// still `expected_previous` (`None`: never projected there). Appended only
328/// after the file write it describes succeeded. Returns `false`, writing
329/// nothing, when another srv projected meanwhile — the caller re-reads.
330pub(crate) fn record_projected(
331    fs: &FileStore,
332    agent_uid: &str,
333    dir_id: &str,
334    file: &str,
335    version: &str,
336    expected_previous: Option<&str>,
337) -> Result<bool, StoreError> {
338    validate_file(file)?;
339    let zone = zone_or_err(agent_uid)?;
340    fs.zone_txn(&zone, |z| {
341        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
342        let current = heads.projected.get(dir_id).and_then(|m| m.get(file)).map(String::as_str);
343        if current == Some(version) {
344            return Ok(true);
345        }
346        if current != expected_previous {
347            return Ok(false);
348        }
349        set_projected(z, &mut heads, dir_id, file, version)?;
350        Ok(true)
351    })
352}
353
354/// Forget every projection into `dir_id`: the folder no longer holds what
355/// was written there (it was removed or emptied), so each file must read as
356/// never projected — to be written back — not as deleted.
357pub(crate) fn reset_projections(fs: &FileStore, agent_uid: &str, dir_id: &str) -> Result<(), StoreError> {
358    let zone = zone_or_err(agent_uid)?;
359    fs.zone_txn(&zone, |z| {
360        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
361        if heads.projected.remove(dir_id).is_some() {
362            write_heads(z, &heads)?;
363        }
364        Ok(())
365    })
366}
367
368/// Which of `shas` another agent's record holds (a blob in its zone).
369pub(crate) fn held_by_other_records(
370    fs: &FileStore,
371    agent_uid: &str,
372    shas: &[String],
373) -> Result<std::collections::BTreeSet<String>, StoreError> {
374    let own = zone_or_err(agent_uid)?;
375    let mut found = std::collections::BTreeSet::new();
376    if shas.is_empty() {
377        return Ok(found);
378    }
379    for zone in fs.get_all_zone_ids()? {
380        if zone == own || !(zone.starts_with("agent-uid:") && zone.ends_with(":memory")) {
381            continue;
382        }
383        for sha in shas {
384            if !found.contains(sha) && fs.stat(&zone, &format!("blob/{sha}"))?.is_some() {
385                found.insert(sha.clone());
386            }
387        }
388    }
389    Ok(found)
390}
391
392/// Keep `files` (name, content hash) found in `dir_id` out of the record,
393/// for the adoption list. One transaction.
394pub(crate) fn hold_for_adoption(fs: &FileStore, agent_uid: &str, dir_id: &str, files: &[(String, String)]) -> Result<(), StoreError> {
395    if files.is_empty() {
396        return Ok(());
397    }
398    let zone = zone_or_err(agent_uid)?;
399    fs.zone_txn(&zone, |z| {
400        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
401        let per_dir = heads.held.entry(dir_id.to_string()).or_default();
402        for (file, sha) in files {
403            per_dir.insert(file.clone(), sha.clone());
404        }
405        write_heads(z, &heads)
406    })
407}
408
409/// Remember that MEMORY.md has had a line for each of `files`.
410pub(crate) fn mark_conflicts_indexed(fs: &FileStore, agent_uid: &str, files: &[String]) -> Result<(), StoreError> {
411    if files.is_empty() {
412        return Ok(());
413    }
414    let zone = zone_or_err(agent_uid)?;
415    fs.zone_txn(&zone, |z| {
416        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
417        let before = heads.indexed_conflicts.len();
418        heads.indexed_conflicts.extend(files.iter().cloned());
419        if heads.indexed_conflicts.len() != before {
420            write_heads(z, &heads)?;
421        }
422        Ok(())
423    })
424}
425
426/// Stop holding `files` in `dir_id` — a human adopted them (§2.1.4).
427pub(crate) fn release_held(fs: &FileStore, agent_uid: &str, dir_id: &str, files: &[String]) -> Result<(), StoreError> {
428    let zone = zone_or_err(agent_uid)?;
429    fs.zone_txn(&zone, |z| {
430        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
431        let Some(per_dir) = heads.held.get_mut(dir_id) else { return Ok(()) };
432        for f in files {
433            per_dir.remove(f);
434        }
435        if per_dir.is_empty() {
436            heads.held.remove(dir_id);
437        }
438        write_heads(z, &heads)
439    })
440}
441
442/// A version from the per-channel history tables, to import.
443pub(crate) struct ImportedVersion {
444    pub file: String,
445    pub body: Vec<u8>,
446    pub source: String,
447    pub source_detail: String,
448    pub created_at_ms: i64,
449}
450
451/// Import the agent's earlier history, once: every version goes into the
452/// log, oldest first and chained per file, keeping its source and time.
453/// History only — no head changes, so a file deleted long ago is never
454/// written back; heads still come from the folder. A version whose content
455/// the record already has for that file is skipped. Returns how many were
456/// imported; `None` if the record already imported its history.
457pub(crate) fn import_history(fs: &FileStore, agent_uid: &str, rows: &[ImportedVersion]) -> Result<Option<usize>, StoreError> {
458    let zone = zone_or_err(agent_uid)?;
459    fs.zone_txn(&zone, |z| {
460        let mut heads = read_heads(z.read(HEADS_FILE)?)?;
461        if heads.imported_at_ms.is_some() {
462            return Ok(None);
463        }
464        let count = log_history(z, rows)?;
465        heads.imported_at_ms = Some(agentmux_common::time::now_ms());
466        write_heads(z, &heads)?;
467        Ok(Some(count))
468    })
469}
470
471/// Add `rows` to the agent's history — no head changes, content the record
472/// already has for that file skipped. For versions kept beside a head the
473/// caller doesn't replace (adoption of earlier accounts' memory, §2.1.4).
474/// Returns how many were added.
475pub(crate) fn add_history(fs: &FileStore, agent_uid: &str, rows: &[ImportedVersion]) -> Result<usize, StoreError> {
476    let zone = zone_or_err(agent_uid)?;
477    fs.zone_txn(&zone, |z| log_history(z, rows))
478}
479
480/// Log `rows` as history-only versions, oldest first and chained per file,
481/// skipping content the log already has for that file.
482fn log_history(z: &mut crate::backend::storage::filestore::ZoneTxn<'_>, rows: &[ImportedVersion]) -> Result<usize, StoreError> {
483    let log = z.read(LOG_FILE)?.unwrap_or_default();
484    let mut have: std::collections::BTreeSet<(String, String)> = log
485        .split(|b| *b == b'\n')
486        .filter_map(|line| serde_json::from_slice::<Event>(line).ok())
487        .filter_map(|e| match e {
488            Event::Version(v) => v.sha256.map(|sha| (v.file, sha)),
489            _ => None,
490        })
491        .collect();
492    let mut sorted: Vec<&ImportedVersion> = rows.iter().collect();
493    sorted.sort_by_key(|r| r.created_at_ms);
494    let mut last: BTreeMap<&str, String> = BTreeMap::new();
495    let mut lines = Vec::new();
496    for r in sorted {
497        if validate_file(&r.file).is_err() || r.body.len() > MAX_BODY_BYTES {
498            continue;
499        }
500        let sha = sha256_hex(&r.body);
501        if !have.insert((r.file.clone(), sha.clone())) {
502            continue;
503        }
504        z.put_if_absent(&format!("blob/{sha}"), &r.body)?;
505        let version = Version {
506            file: r.file.clone(),
507            version: format!("v_{}", uuid::Uuid::new_v4().simple()),
508            parent: last.get(r.file.as_str()).cloned(),
509            merged_parent: None,
510            sha256: Some(sha),
511            conflicts_with: None,
512            source: r.source.clone(),
513            source_detail: r.source_detail.clone(),
514            created_at_ms: r.created_at_ms,
515        };
516        last.insert(&r.file, version.version.clone());
517        lines.extend(log_line(&Event::Version(version))?);
518    }
519    let count = lines.iter().filter(|b| **b == b'\n').count();
520    if !lines.is_empty() {
521        z.append_lines(LOG_FILE, &lines)?;
522    }
523    Ok(count)
524}
525
526/// Every version's content hash (`None` for a tombstone), by version id —
527/// read once, for a caller asking about many versions (a reconcile pass).
528pub(crate) fn version_shas(fs: &FileStore, agent_uid: &str) -> Result<std::collections::HashMap<String, Option<String>>, StoreError> {
529    let zone = zone_or_err(agent_uid)?;
530    let log = fs.read_files_consistent(&zone, &[LOG_FILE])?.pop().flatten().unwrap_or_default();
531    Ok(log
532        .split(|b| *b == b'\n')
533        .filter_map(|line| serde_json::from_slice::<Event>(line).ok())
534        .filter_map(|e| match e {
535            Event::Version(v) => Some((v.version, v.sha256)),
536            _ => None,
537        })
538        .collect())
539}
540
541/// Every version of `file`, oldest first — by time, since imported history
542/// is logged after the versions it predates.
543#[cfg(test)]
544pub(crate) fn history(fs: &FileStore, agent_uid: &str, file: &str) -> Result<Vec<Version>, StoreError> {
545    let zone = zone_or_err(agent_uid)?;
546    let log = fs.read_files_consistent(&zone, &[LOG_FILE])?.pop().flatten().unwrap_or_default();
547    let mut versions: Vec<Version> = log
548        .split(|b| *b == b'\n')
549        .filter_map(|line| serde_json::from_slice::<Event>(line).ok())
550        .filter_map(|e| match e {
551            Event::Version(v) if v.file == file => Some(v),
552            _ => None,
553        })
554        .collect();
555    versions.sort_by_key(|v| v.created_at_ms);
556    Ok(versions)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    const UID: &str = "93d0dfdd-0000-4000-8000-000000000001";
564
565    fn new<'a>(file: &'a str, body: Option<&'a [u8]>, parent: Option<&'a str>) -> NewVersion<'a> {
566        NewVersion {
567            file,
568            body,
569            expected_parent: parent,
570            merged_parent: None,
571            conflicts_with: None,
572            source: "agent",
573            source_detail: "",
574            project_to: None,
575        }
576    }
577
578    fn appended(o: AppendOutcome) -> Version {
579        match o {
580            AppendOutcome::Appended(v) => v,
581            other => panic!("expected an append, got {other:?}"),
582        }
583    }
584
585    #[test]
586    fn versions_chain_and_heads_follow() {
587        let fs = FileStore::open_in_memory().unwrap();
588        let v1 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
589        let v2 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap());
590        assert_eq!(v2.parent.as_deref(), Some(v1.version.as_str()));
591
592        let h = heads(&fs, UID).unwrap();
593        assert_eq!(h.files["MEMORY.md"].version, v2.version);
594        let sha = h.files["MEMORY.md"].sha256.clone().unwrap();
595        assert_eq!(body(&fs, UID, &sha).unwrap().as_deref(), Some(&b"two"[..]));
596        let hist = history(&fs, UID, "MEMORY.md").unwrap();
597        assert_eq!(hist.iter().map(|v| v.version.as_str()).collect::<Vec<_>>(), [v1.version.as_str(), v2.version.as_str()]);
598    }
599
600    #[test]
601    fn a_stale_parent_is_refused_and_nothing_is_written() {
602        let fs = FileStore::open_in_memory().unwrap();
603        let v1 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
604        let _v2 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap());
605
606        let stale = append_version(&fs, UID, new("MEMORY.md", Some(b"three"), Some(&v1.version))).unwrap();
607        assert!(matches!(stale, AppendOutcome::StaleParent { head: Some(_) }));
608        assert_eq!(history(&fs, UID, "MEMORY.md").unwrap().len(), 2);
609        assert!(body(&fs, UID, &sha256_hex(b"three")).unwrap().is_none(), "no blob for a refused append");
610    }
611
612    #[test]
613    fn the_same_content_on_the_same_parent_is_recorded_once() {
614        let fs = FileStore::open_in_memory().unwrap();
615        let v1 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
616        let again = append_version(&fs, UID, new("MEMORY.md", Some(b"one"), Some(&v1.version))).unwrap();
617        assert_eq!(again, AppendOutcome::Unchanged(v1.version.clone()));
618        assert_eq!(history(&fs, UID, "MEMORY.md").unwrap().len(), 1);
619    }
620
621    #[test]
622    fn a_tombstone_removes_the_file_from_the_live_set() {
623        let fs = FileStore::open_in_memory().unwrap();
624        let v1 = appended(append_version(&fs, UID, new("topic.md", Some(b"x"), None)).unwrap());
625        let t = appended(append_version(&fs, UID, new("topic.md", None, Some(&v1.version))).unwrap());
626        assert!(t.is_tombstone());
627        assert_eq!(heads(&fs, UID).unwrap().live().count(), 0);
628        let never = append_version(&fs, UID, new("never.md", None, None)).unwrap();
629        assert_eq!(never, AppendOutcome::NothingToRecord);
630    }
631
632    #[test]
633    fn projections_are_kept_per_directory() {
634        let fs = FileStore::open_in_memory().unwrap();
635        let v1 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
636        assert!(record_projected(&fs, UID, "/a/memory", "MEMORY.md", &v1.version, None).unwrap());
637        assert!(record_projected(&fs, UID, "/a/memory", "MEMORY.md", &v1.version, None).unwrap()); // already so
638        let v2 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap());
639        // Another srv projected meanwhile: a stale expectation writes nothing.
640        assert!(!record_projected(&fs, UID, "/a/memory", "MEMORY.md", &v2.version, None).unwrap());
641        assert!(record_projected(&fs, UID, "/a/memory", "MEMORY.md", &v2.version, Some(&v1.version)).unwrap());
642        let h = heads(&fs, UID).unwrap();
643        assert_eq!(h.projected["/a/memory"]["MEMORY.md"], v2.version);
644        assert!(!h.projected.contains_key("/b/memory"));
645    }
646
647    #[test]
648    fn oversized_bodies_and_unsafe_uids_are_refused() {
649        let fs = FileStore::open_in_memory().unwrap();
650        let big = vec![b'x'; MAX_BODY_BYTES + 1];
651        assert!(append_version(&fs, UID, new("big.md", Some(&big), None)).is_err());
652        assert!(zone_for("../escape").is_none());
653        assert!(heads(&fs, "../escape").is_err());
654        for bad in ["", ".", "..", "../x.md", "a/b.md", "a\\b.md"] {
655            assert!(append_version(&fs, UID, new(bad, Some(b"x"), None)).is_err(), "{bad:?}");
656        }
657        assert!(body(&fs, UID, "../../etc").is_err());
658    }
659
660    /// Two srvs capture the same provider write from the same parent: the
661    /// second finds it already recorded, not a conflict.
662    #[test]
663    fn a_second_capture_of_the_same_change_is_already_recorded() {
664        let fs = FileStore::open_in_memory().unwrap();
665        let v1 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
666        let first = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap());
667        let second = append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap();
668        assert_eq!(second, AppendOutcome::Unchanged(first.version.clone()));
669        // A different change from the same parent is still stale.
670        let other = append_version(&fs, UID, new("MEMORY.md", Some(b"three"), Some(&v1.version))).unwrap();
671        assert!(matches!(other, AppendOutcome::StaleParent { .. }));
672    }
673
674    #[test]
675    fn a_version_and_its_projection_commit_together() {
676        let fs = FileStore::open_in_memory().unwrap();
677        let mut nv = new("MEMORY.md", Some(b"one"), None);
678        nv.project_to = Some("/a/memory");
679        let v1 = appended(append_version(&fs, UID, nv).unwrap());
680        assert_eq!(heads(&fs, UID).unwrap().projected["/a/memory"]["MEMORY.md"], v1.version);
681    }
682
683    /// An unreadable heads.json is an error, never silently empty — the next
684    /// write would otherwise replace every head with its own.
685    #[test]
686    fn an_unreadable_heads_file_is_an_error_not_empty() {
687        let fs = FileStore::open_in_memory().unwrap();
688        let zone = zone_for(UID).unwrap();
689        appended(append_version(&fs, UID, new("a.md", Some(b"a"), None)).unwrap());
690        fs.zone_txn(&zone, |z| z.put(HEADS_FILE, b"{not json")).unwrap();
691        assert!(heads(&fs, UID).is_err());
692        assert!(append_version(&fs, UID, new("b.md", Some(b"b"), None)).is_err());
693    }
694
695    /// Fields a newer build added survive this build's rewrite.
696    #[test]
697    fn unknown_heads_fields_survive_a_rewrite() {
698        let fs = FileStore::open_in_memory().unwrap();
699        let zone = zone_for(UID).unwrap();
700        let v1 = appended(append_version(&fs, UID, new("a.md", Some(b"a"), None)).unwrap());
701        let mut h: serde_json::Value = serde_json::to_value(heads(&fs, UID).unwrap()).unwrap();
702        h["future"] = serde_json::json!(1);
703        h["files"]["a.md"]["future_file_field"] = serde_json::json!("x");
704        fs.zone_txn(&zone, |z| z.put(HEADS_FILE, &serde_json::to_vec(&h).unwrap())).unwrap();
705        appended(append_version(&fs, UID, new("a.md", Some(b"a2"), Some(&v1.version))).unwrap());
706        let after: serde_json::Value = serde_json::to_value(heads(&fs, UID).unwrap()).unwrap();
707        assert_eq!(after["future"], 1);
708        assert_eq!(after["files"]["a.md"]["future_file_field"], "x");
709    }
710
711    /// Two handles on one database stand in for two srv processes appending
712    /// to the same file from the same parent: exactly one wins.
713    #[test]
714    fn two_processes_from_the_same_parent_never_fork_the_chain() {
715        let dir = tempfile::tempdir().unwrap();
716        let path = dir.path().join("filestore.db");
717        let a = FileStore::open(&path).unwrap();
718        let b = FileStore::open(&path).unwrap();
719        let v1 = appended(append_version(&a, UID, new("MEMORY.md", Some(b"one"), None)).unwrap());
720        let from_a = append_version(&a, UID, new("MEMORY.md", Some(b"from a"), Some(&v1.version))).unwrap();
721        let from_b = append_version(&b, UID, new("MEMORY.md", Some(b"from b"), Some(&v1.version))).unwrap();
722        assert!(matches!(from_a, AppendOutcome::Appended(_)));
723        assert!(matches!(from_b, AppendOutcome::StaleParent { .. }));
724        assert_eq!(history(&b, UID, "MEMORY.md").unwrap().len(), 2);
725    }
726}