1use 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";
37pub(crate) const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
39
40pub(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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "event", rename_all = "snake_case")]
53pub(crate) enum Event {
54 Version(Version),
55 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 pub parent: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub merged_parent: Option<String>,
69 pub sha256: Option<String>,
71 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub(crate) struct Head {
91 pub version: String,
92 #[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 #[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 #[serde(default)]
108 pub files: BTreeMap<String, Head>,
109 #[serde(default)]
111 pub projected: BTreeMap<String, BTreeMap<String, String>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub imported_at_ms: Option<i64>,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121 pub held: BTreeMap<String, BTreeMap<String, String>>,
122 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
125 pub indexed_conflicts: std::collections::BTreeSet<String>,
126 #[serde(flatten)]
128 pub extra: serde_json::Map<String, serde_json::Value>,
129}
130
131impl Heads {
132 pub(crate) fn live(&self) -> impl Iterator<Item = (&String, &Head)> {
134 self.files.iter().filter(|(_, h)| h.sha256.is_some())
135 }
136}
137
138pub(crate) struct NewVersion<'a> {
140 pub file: &'a str,
141 pub body: Option<&'a [u8]>,
143 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 pub project_to: Option<&'a str>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
156pub(crate) enum AppendOutcome {
157 Appended(Version),
158 Unchanged(String),
162 NothingToRecord,
164 StaleParent { head: Option<String> },
166}
167
168pub(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
190fn 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
211pub(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
218pub(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
226pub(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 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
302fn 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
326pub(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
354pub(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
368pub(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
392pub(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
409pub(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
426pub(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
442pub(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
451pub(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
471pub(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
480fn 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
526pub(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#[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()); let v2 = appended(append_version(&fs, UID, new("MEMORY.md", Some(b"two"), Some(&v1.version))).unwrap());
639 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 #[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 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 #[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 #[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 #[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}