1use serde::Serialize;
30
31use super::{Engine, EngineError};
32use crate::workspace::MountStorage;
33
34pub const HISTORY_PAGE_DEFAULT: usize = 50;
36pub const HISTORY_PAGE_MAX: usize = 200;
38
39#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
41pub struct EntityTouch {
42 pub reference: String,
46 pub timestamp: i64,
48 pub id_at_touch: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
56 pub verb: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub subject: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub note: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub actor: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub client: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub tool: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
74 pub renamed_from: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub renamed_to: Option<String>,
78 #[serde(skip_serializing_if = "Vec::is_empty", default)]
82 pub batch_entity_ids: Vec<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub logical_op: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
90 pub role: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
95 pub identity: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
101#[serde(tag = "kind", rename_all = "lowercase")]
102pub enum StoryStart {
103 Recorded,
106 Truncated { reason: String },
110}
111
112#[derive(Debug, Clone, Serialize)]
115pub struct EntityHistoryReport {
116 pub mem: String,
117 pub entity_id: String,
119 pub touches: Vec<EntityTouch>,
121 pub total_recorded: usize,
124 #[serde(skip_serializing_if = "Option::is_none")]
127 pub next_cursor: Option<String>,
128 pub story_start: StoryStart,
130 #[serde(skip_serializing_if = "Vec::is_empty", default)]
133 pub limitations: Vec<String>,
134}
135
136fn parse_rename_pair(field: &str) -> Option<(String, String)> {
141 if field.contains("(cross-mem rewrite") {
142 return None;
143 }
144 let mut parts = field.splitn(2, " → ");
145 let old = parts.next()?.trim();
146 let new = parts.next()?.trim();
147 if old.is_empty() || new.is_empty() {
148 return None;
149 }
150 Some((old.to_string(), new.to_string()))
151}
152
153#[derive(Debug, Clone, serde::Serialize)]
157pub struct ProvenanceRecord {
158 #[serde(skip_serializing_if = "Option::is_none")]
161 pub actor: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub client: Option<String>,
165 pub role: String,
169 #[serde(skip_serializing_if = "Option::is_none")]
173 pub identity: Option<String>,
174 pub timestamp: i64,
176 pub reference: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
183 pub verb: Option<String>,
184}
185
186#[derive(Debug, Clone, serde::Serialize)]
190pub struct EntityProvenance {
191 #[serde(skip_serializing_if = "Option::is_none")]
195 pub created_by: Option<ProvenanceRecord>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub last_modified_by: Option<ProvenanceRecord>,
199 #[serde(skip_serializing_if = "std::ops::Not::not")]
202 pub story_truncated: bool,
203 pub check_state: String,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub last_check: Option<crate::check::CheckRecord>,
212 pub conformance_state: String,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub last_conformance_check: Option<crate::check::CheckRecord>,
220}
221
222fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
223 ProvenanceRecord {
224 actor: t.actor.clone(),
225 client: t.client.clone(),
226 role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
227 identity: t.identity.clone(),
228 timestamp: t.timestamp,
229 reference: t.reference.clone(),
230 verb: t.verb.clone(),
231 }
232}
233
234impl Engine {
235 pub fn entity_provenance(
244 &self,
245 mem: &str,
246 entity_id: &str,
247 ) -> Result<EntityProvenance, EngineError> {
248 let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
249 let newest = report.touches.first().cloned();
250 while let Some(cursor) = report.next_cursor.clone() {
252 report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
253 }
254 let oldest = report.touches.last().or(newest.as_ref()).cloned();
255 let truncated = !matches!(report.story_start, StoryStart::Recorded);
256 let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
257 let (conformance_state, last_conformance_check) =
258 self.entity_conformance_state(mem, entity_id)?;
259 Ok(EntityProvenance {
260 created_by: match (&oldest, truncated) {
261 (Some(t), false) => Some(touch_to_record(t)),
262 _ => None,
263 },
264 last_modified_by: newest.as_ref().map(touch_to_record),
265 story_truncated: truncated,
266 check_state: check_state.as_str().to_string(),
267 last_check,
268 conformance_state: conformance_state.as_str().to_string(),
269 last_conformance_check,
270 })
271 }
272
273 pub fn entity_history(
284 &self,
285 mem: &str,
286 entity_id: &str,
287 page_size: Option<usize>,
288 cursor: Option<&str>,
289 ) -> Result<EntityHistoryReport, EngineError> {
290 let m = self.find_mount(mem)?;
291
292 let known = self
296 .store
297 .all_entities()
298 .any(|e| e.mem == mem && e.id.0 == entity_id);
299 if !known {
300 return Err(EngineError::NotFound {
301 id: entity_id.to_string(),
302 });
303 }
304
305 let mut limitations: Vec<String> = Vec::new();
306
307 let touches: Vec<EntityTouch> = match &m.mount.storage {
309 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
310 Some(hook) => {
311 let backend_changes = (hook.changes_since)(
312 gitdir,
313 branch,
314 mem,
315 crate::ops::EMPTY_TREE_SHA,
316 crate::ops::RENAME_SIMILARITY_DEFAULT,
317 )
318 .map_err(EngineError::Backend)?;
319 filter_notes_for_entity(entity_id, &backend_changes.notes)
320 }
321 None => {
322 limitations.push(
323 "this build carries no git-branch history walk — the recorded story \
324 is not reachable"
325 .to_string(),
326 );
327 Vec::new()
328 }
329 },
330 MountStorage::Folder { .. } | MountStorage::InMemory => {
331 limitations.push(
332 "changelog-backed history: rename records carry only the post-rename id \
333 (prior-id chains are not stitchable) and batch mutations record no \
334 per-entity attribution on this backend"
335 .to_string(),
336 );
337 let records = m
338 .backend
339 .read_provenance(None)
340 .map_err(EngineError::Backend)?;
341 filter_provenance_for_entity(entity_id, &records)
342 }
343 MountStorage::Archive { .. } => {
344 return Err(EngineError::InvalidInput(format!(
345 "mem '{mem}' is an archive mount — archives record no history at the \
346 engine seam; open the source mem for the entity's story"
347 )));
348 }
349 };
350
351 let story_start = match touches.last() {
356 Some(oldest)
364 if matches!(
365 oldest.verb.as_deref(),
366 Some("create") | Some("batch-create")
367 ) =>
368 {
369 StoryStart::Recorded
370 }
371 Some(oldest) => StoryStart::Truncated {
372 reason: format!(
373 "oldest recorded touch is a {} of `{}`, not the entity's creation — \
374 earlier history (an unrecorded prior id, or records predating the \
375 provenance log) is not reachable",
376 oldest.verb.as_deref().unwrap_or("touch"),
377 oldest.id_at_touch
378 ),
379 },
380 None => StoryStart::Truncated {
381 reason: "no touches recorded — the entity predates this mem's provenance \
382 record (an adopted or migrated mem)"
383 .to_string(),
384 },
385 };
386
387 let size = page_size
389 .unwrap_or(HISTORY_PAGE_DEFAULT)
390 .clamp(1, HISTORY_PAGE_MAX);
391 let start_idx = match cursor {
392 None => 0,
393 Some(c) => {
394 let pos = parse_cursor(c).and_then(|(reference, k)| {
395 touches
396 .iter()
397 .enumerate()
398 .filter(|(_, t)| t.reference == reference)
399 .nth(k)
400 .map(|(i, _)| i + 1)
401 });
402 pos.ok_or_else(|| EngineError::InvalidChangesCursor {
403 mem: mem.to_string(),
404 since: c.to_string(),
405 })?
406 }
407 };
408 let total_recorded = touches.len();
409 let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
410 .iter()
411 .take(size)
412 .cloned()
413 .collect();
414 let next_cursor = if start_idx + page.len() < total_recorded {
415 page.last().map(|last| {
416 let k = touches[..start_idx + page.len()]
417 .iter()
418 .filter(|t| t.reference == last.reference)
419 .count()
420 - 1;
421 format!("{}@{k}", last.reference)
422 })
423 } else {
424 None
425 };
426
427 Ok(EntityHistoryReport {
428 mem: mem.to_string(),
429 entity_id: entity_id.to_string(),
430 touches: page,
431 total_recorded,
432 next_cursor,
433 story_start,
434 limitations,
435 })
436 }
437}
438
439fn parse_cursor(c: &str) -> Option<(String, usize)> {
444 let (reference, k) = c.rsplit_once('@')?;
445 if reference.is_empty() {
446 return None;
447 }
448 Some((reference.to_string(), k.parse().ok()?))
449}
450
451fn filter_notes_for_entity(
466 entity_id: &str,
467 notes: &[crate::ops::agent_notes::CommitNote],
468) -> Vec<EntityTouch> {
469 let mut current = entity_id.to_string();
470 let mut out: Vec<EntityTouch> = Vec::new();
471 for n in notes {
472 let base = |id_at: &str| EntityTouch {
473 reference: n.sha.clone(),
474 timestamp: n.timestamp,
475 id_at_touch: id_at.to_string(),
476 verb: n.tool_verb.clone(),
477 subject: Some(n.subject.clone()),
478 note: n.note.clone(),
479 actor: n.actor.clone(),
480 client: n.client.clone(),
481 tool: n.tool.clone(),
482 renamed_from: None,
483 renamed_to: None,
484 batch_entity_ids: Vec::new(),
485 logical_op: n.logical_operation_id.clone(),
486 role: n.role.clone(),
487 identity: n.identity.clone(),
488 };
489 if n.tool_verb.as_deref() == Some("rename") {
490 if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
491 && new == current
492 {
493 let mut touch = base(&new);
494 touch.renamed_from = Some(old.clone());
495 touch.renamed_to = Some(new);
496 out.push(touch);
497 current = old;
498 }
499 continue;
500 }
501 if n.entity_id.as_deref() == Some(current.as_str()) {
502 let is_create = n.tool_verb.as_deref() == Some("create");
503 out.push(base(¤t));
504 if is_create {
505 break;
506 }
507 } else if n.entity_ids.iter().any(|id| id == ¤t) {
508 let mut touch = base(¤t);
509 touch.batch_entity_ids = n.entity_ids.clone();
510 out.push(touch);
511 if n.tool_verb.as_deref() == Some("batch-create") {
514 break;
515 }
516 }
517 }
518 out
519}
520
521fn filter_provenance_for_entity(
532 entity_id: &str,
533 records: &[crate::provenance::Provenance],
534) -> Vec<EntityTouch> {
535 let matching: Vec<&crate::provenance::Provenance> = records
536 .iter()
537 .filter(|p| p.entity.as_deref() == Some(entity_id))
538 .collect();
539 let birth = matching
540 .iter()
541 .rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
542 .unwrap_or(0);
543 let mut out: Vec<EntityTouch> = matching[birth..]
544 .iter()
545 .map(|p| {
546 let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
547 EntityTouch {
548 reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
549 timestamp: p
550 .timestamp
551 .duration_since(std::time::UNIX_EPOCH)
552 .map(|d| d.as_secs() as i64)
553 .unwrap_or(0),
554 id_at_touch: entity_id.to_string(),
555 verb: Some(p.kind.as_str().to_string()),
556 subject: None,
557 note: p.note.clone(),
558 actor: Some(p.actor.as_trailer().to_string()),
559 client: p
560 .client
561 .as_ref()
562 .map(|c| format!("{}@{}", c.name, c.version)),
563 tool: None,
564 renamed_from: None,
565 renamed_to: is_rename.then(|| entity_id.to_string()),
566 batch_entity_ids: Vec::new(),
567 logical_op: p.logical_operation_id.clone(),
568 role: p.role.as_trailer().map(str::to_string),
569 identity: p.identity.clone(),
570 }
571 })
572 .collect();
573 out.reverse();
574 out
575}
576
577#[cfg(test)]
578mod tests {
579 use crate::storage::MemWriter;
580
581 #[test]
589 fn batch_create_note_is_the_entitys_creation() {
590 use crate::ops::agent_notes::CommitNote;
591 let note = |sha: &str, verb: &str, entity_id: Option<&str>, ids: Vec<&str>| CommitNote {
592 mem: "m".into(),
593 sha: sha.into(),
594 subject: format!("memstead: {verb}"),
595 tool_verb: Some(verb.into()),
596 entity_id: entity_id.map(str::to_string),
597 note: None,
598 actor: Some("cli".into()),
599 tool: None,
600 client: None,
601 logical_operation_id: None,
602 role: Some("author".into()),
603 identity: Some("author-x".into()),
604 entity_ids: ids.into_iter().map(str::to_string).collect(),
605 timestamp: 1,
606 };
607 let notes = vec![
610 note("c3", "update", Some("m--thing"), vec![]),
611 note("c2", "batch-create", None, vec!["m--other", "m--thing"]),
612 note("c1", "update", Some("m--thing"), vec![]),
613 ];
614 let touches = super::filter_notes_for_entity("m--thing", ¬es);
615 assert_eq!(
616 touches.len(),
617 2,
618 "the walk stops at the batch create: {touches:?}"
619 );
620 let oldest = touches.last().unwrap();
621 assert_eq!(oldest.verb.as_deref(), Some("batch-create"));
622 assert_eq!(oldest.identity.as_deref(), Some("author-x"));
623 assert!(
624 matches!(
625 oldest.verb.as_deref(),
626 Some("create") | Some("batch-create")
627 ),
628 "the story-start acceptance covers the batch verb"
629 );
630 }
631
632 const SEED: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
633
634 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
638 let dir = tmp.path().join("specs");
639 if !dir.exists() {
640 std::fs::create_dir_all(&dir).unwrap();
641 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
642 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
643 .unwrap();
644 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
645 }
646 let mount = crate::Mount {
647 mem: "specs".to_string(),
648 schema: Some(memstead_schema::SchemaRef::new(
649 "default",
650 semver::Version::new(1, 0, 0),
651 )),
652 storage: crate::MountStorage::Folder { path: dir.clone() },
653 capability: crate::MountCapability::Write,
654 lifecycle: crate::MountLifecycle::Eager,
655 cross_linkable: false,
656 migration_target: None,
657 };
658 let backend =
659 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
660 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
661 }
662
663 fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
664 let outcome = engine
665 .create_entity(
666 crate::CreateEntityArgs {
667 mem: "specs".to_string(),
668 title: title.to_string(),
669 entity_type: "spec".to_string(),
670 sections: [
671 ("identity".to_string(), "x".to_string()),
672 ("purpose".to_string(), "y".to_string()),
673 ]
674 .into_iter()
675 .collect(),
676 metadata: Default::default(),
677 relations: Vec::new(),
678 anchors: Vec::new(),
679 dry_run: false,
680 },
681 crate::vcs::Actor::Cli,
682 None,
683 Some(note),
684 )
685 .unwrap();
686 outcome.id.0
687 }
688
689 fn update(engine: &mut crate::Engine, id: &str, note: &str) {
690 engine
691 .update_entity(
692 crate::UpdateEntityArgs {
693 id: crate::EntityId(id.to_string()),
694 expected_hash: None,
695 sections: [("identity".to_string(), format!("touched: {note}"))]
696 .into_iter()
697 .collect(),
698 append_sections: Default::default(),
699 patch_sections: Default::default(),
700 sections_unset: Vec::new(),
701 metadata: Default::default(),
702 metadata_unset: Vec::new(),
703 dry_run: false,
704 declare_relations: Vec::new(),
705 anchors: Vec::new(),
706 relations_unset: Vec::new(),
707 anchors_unset: Vec::new(),
708 },
709 crate::vcs::Actor::App,
710 None,
711 Some(note),
712 )
713 .unwrap();
714 }
715
716 #[test]
717 fn folder_history_serves_touches_with_stated_limitations() {
718 let tmp = tempfile::TempDir::new().unwrap();
719 let mut engine = folder_engine(&tmp);
720 let id = create(&mut engine, "Story", "born");
721 update(&mut engine, &id, "grew");
722
723 let report = engine.entity_history("specs", &id, None, None).unwrap();
724 assert_eq!(report.total_recorded, 2);
725 assert_eq!(report.touches.len(), 2);
726 assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
728 assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
729 assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
730 assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
731 assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
732 assert_eq!(report.story_start, super::StoryStart::Recorded);
733 assert!(
734 report
735 .limitations
736 .iter()
737 .any(|l| l.contains("rename records carry only the post-rename id")),
738 "folder limitations must be stated: {:?}",
739 report.limitations
740 );
741 assert!(report.touches.iter().all(|t| t.id_at_touch == id));
743 }
744
745 #[test]
746 fn folder_rename_truncates_visibly_not_silently() {
747 let tmp = tempfile::TempDir::new().unwrap();
748 let mut engine = folder_engine(&tmp);
749 let id = create(&mut engine, "Before Rename", "born");
750 let outcome = engine
751 .rename_entity(
752 crate::RenameEntityArgs {
753 id: crate::EntityId(id.clone()),
754 expected_hash: None,
755 new_title: "After Rename".to_string(),
756 },
757 crate::vcs::Actor::Cli,
758 None,
759 Some("renamed"),
760 )
761 .unwrap();
762 let new_id = outcome.new_id.0;
763
764 let report = engine.entity_history("specs", &new_id, None, None).unwrap();
768 assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
769 assert!(report.touches[0].renamed_from.is_none());
770 match &report.story_start {
771 super::StoryStart::Truncated { reason } => {
772 assert!(
773 reason.contains("not the entity's creation"),
774 "reason must explain the truncation: {reason}"
775 );
776 }
777 other => panic!("expected visible truncation, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn reused_id_never_absorbs_the_previous_holders_story() {
783 let tmp = tempfile::TempDir::new().unwrap();
789 let mut engine = folder_engine(&tmp);
790 let id = create(&mut engine, "Slot", "first holder");
791 update(&mut engine, &id, "first holder grew");
792 engine
793 .rename_entity(
794 crate::RenameEntityArgs {
795 id: crate::EntityId(id.clone()),
796 expected_hash: None,
797 new_title: "Slot Moved".to_string(),
798 },
799 crate::vcs::Actor::Cli,
800 None,
801 Some("moved away"),
802 )
803 .unwrap();
804 let reused = create(&mut engine, "Slot", "second holder");
805 assert_eq!(reused, id, "the slug is reused");
806
807 let report = engine.entity_history("specs", &reused, None, None).unwrap();
808 assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
809 assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
810 assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
811 assert!(matches!(report.story_start, super::StoryStart::Recorded));
812 }
813
814 #[test]
815 fn refusals_are_typed_never_empty_stories() {
816 let tmp = tempfile::TempDir::new().unwrap();
817 let mut engine = folder_engine(&tmp);
818 let id = create(&mut engine, "Real", "born");
819
820 let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
822 assert_eq!(err.code(), "UNKNOWN_MEM");
823 let err = engine
825 .entity_history("specs", "specs--nope", None, None)
826 .unwrap_err();
827 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
828 let err = engine
830 .entity_history("specs", &id, None, Some("zzz@0"))
831 .unwrap_err();
832 assert_eq!(err.code(), "INVALID_CURSOR");
833 let err = engine
834 .entity_history("specs", &id, None, Some("no-separator"))
835 .unwrap_err();
836 assert_eq!(err.code(), "INVALID_CURSOR");
837 }
838
839 #[test]
840 fn pre_changelog_entity_states_the_empty_record() {
841 let tmp = tempfile::TempDir::new().unwrap();
846 let engine = folder_engine(&tmp);
847 let report = engine
848 .entity_history("specs", "specs--seed", None, None)
849 .unwrap();
850 assert!(report.touches.is_empty());
851 assert!(matches!(
852 report.story_start,
853 super::StoryStart::Truncated { .. }
854 ));
855 }
856
857 #[test]
858 fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
859 let dir = tempfile::TempDir::new().unwrap();
864 let backend = crate::storage::InMemoryBackend::new();
865 crate::backend::MemBackend::write_entity(
866 &backend,
867 std::path::Path::new("seed.md"),
868 SEED.as_bytes(),
869 )
870 .unwrap();
871 crate::backend::MemBackend::commit(
872 &backend,
873 "seed",
874 &crate::vcs::CommitContext::internal(),
875 )
876 .unwrap();
877 let mount = crate::Mount {
878 mem: "specs".to_string(),
879 schema: Some(memstead_schema::SchemaRef::new(
880 "default",
881 semver::Version::new(1, 0, 0),
882 )),
883 storage: crate::MountStorage::Archive {
884 path: dir.path().join("sealed.mem"),
885 },
886 capability: crate::MountCapability::Write,
887 lifecycle: crate::MountLifecycle::Eager,
888 cross_linkable: false,
889 migration_target: None,
890 };
891 let engine = crate::Engine::from_mounts(vec![(
892 mount,
893 Box::new(backend) as Box<dyn crate::MemBackend>,
894 )])
895 .unwrap();
896 let err = engine
897 .entity_history("specs", "specs--seed", None, None)
898 .unwrap_err();
899 assert_eq!(err.code(), "INVALID_INPUT");
900 }
901
902 #[test]
903 fn pages_compose_without_gaps_or_duplicates() {
904 let tmp = tempfile::TempDir::new().unwrap();
905 let mut engine = folder_engine(&tmp);
906 let id = create(&mut engine, "Paged", "born");
907 for i in 0..5 {
908 update(&mut engine, &id, &format!("touch {i}"));
909 }
910
911 let full = engine.entity_history("specs", &id, None, None).unwrap();
912 assert_eq!(full.total_recorded, 6);
913 assert!(full.next_cursor.is_none());
914
915 let mut collected: Vec<super::EntityTouch> = Vec::new();
917 let mut cursor: Option<String> = None;
918 loop {
919 let page = engine
920 .entity_history("specs", &id, Some(2), cursor.as_deref())
921 .unwrap();
922 assert!(page.touches.len() <= 2);
923 assert_eq!(page.total_recorded, 6, "every page states the whole");
924 collected.extend(page.touches.clone());
925 match page.next_cursor {
926 Some(c) => cursor = Some(c),
927 None => break,
928 }
929 }
930 assert_eq!(
931 collected, full.touches,
932 "paged walk must equal the single-page story exactly"
933 );
934 }
935}