Now let's take a silly one
1mod coverage;
2mod error;
3mod intern;
4mod projections;
5
6pub use coverage::{Coverage, Resolved};
7pub use error::IndexError;
8pub use knot_types::OfferedKey;
9
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use knot_cob::{ChangePayload, CobStore};
14use knot_cobs::{
15 BlocklistChange, BlocklistCob, CollaboratorsChange, CollaboratorsCob, Grant, MembersChange,
16 MembersCob, RegistryChange, RepoRegistryCob,
17};
18use knot_git::{Layout, Repo};
19use knot_types::{AccountDid, OwnerDid, RepoDid, RepoRkey};
20
21use intern::Interner;
22use projections::{CollaboratorsProjection, GrantSetProjection, KeyProjection, RegistryProjection};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct IndexCoverage {
26 pub members: Coverage,
27 pub blocklist: Coverage,
28 pub collaborators: Coverage,
29 pub registry: Coverage,
30 pub keys: Coverage,
31}
32
33pub struct Index {
34 meta_path: PathBuf,
35 layout: Layout,
36 interner: Interner,
37 members: GrantSetProjection<MembersCob>,
38 blocklist: GrantSetProjection<BlocklistCob>,
39 collaborators: CollaboratorsProjection,
40 registry: RegistryProjection,
41 keys: KeyProjection,
42 generation: AtomicU64,
43}
44
45impl Index {
46 pub fn new(meta_path: impl Into<PathBuf>, layout: Layout) -> Self {
47 Self {
48 meta_path: meta_path.into(),
49 layout,
50 interner: Interner::new(),
51 members: GrantSetProjection::new(),
52 blocklist: GrantSetProjection::new(),
53 collaborators: CollaboratorsProjection::new(),
54 registry: RegistryProjection::new(),
55 keys: KeyProjection::new(),
56 generation: AtomicU64::new(0),
57 }
58 }
59
60 pub fn generation(&self) -> u64 {
61 self.generation.load(Ordering::Acquire)
62 }
63
64 fn bump_generation(&self) {
65 self.generation.fetch_add(1, Ordering::Release);
66 }
67
68 pub fn rebuild(&self) -> Result<(), IndexError> {
69 self.refresh_members()?;
70 self.refresh_blocklist()?;
71 self.refresh_registry()?;
72 Ok(())
73 }
74
75 pub fn warm_collaborators(&self) {
76 self.hosted_repos().iter().for_each(|repo| {
77 let _ = self.ensure_collaborators(repo);
78 });
79 }
80
81 pub fn refresh_members(&self) -> Result<(), IndexError> {
82 let meta = Repo::open(&self.meta_path)?;
83 let store = CobStore::new(&meta);
84 match store.list::<MembersCob>()?.as_slice() {
85 [] => self.members.reset(),
86 [object] => self.members.refresh(&self.interner, &store, *object)?,
87 many => {
88 return Err(IndexError::Ambiguous {
89 type_name: MembersChange::type_name(),
90 count: many.len(),
91 });
92 }
93 }
94 self.bump_generation();
95 Ok(())
96 }
97
98 pub fn refresh_blocklist(&self) -> Result<(), IndexError> {
99 let meta = Repo::open(&self.meta_path)?;
100 let store = CobStore::new(&meta);
101 match store.list::<BlocklistCob>()?.as_slice() {
102 [] => self.blocklist.reset(),
103 [object] => self.blocklist.refresh(&self.interner, &store, *object)?,
104 many => {
105 return Err(IndexError::Ambiguous {
106 type_name: BlocklistChange::type_name(),
107 count: many.len(),
108 });
109 }
110 }
111 self.bump_generation();
112 Ok(())
113 }
114
115 pub fn refresh_registry(&self) -> Result<(), IndexError> {
116 let meta = Repo::open(&self.meta_path)?;
117 let store = CobStore::new(&meta);
118 let evacuated = match store.list::<RepoRegistryCob>()?.as_slice() {
119 [] => self.registry.reset(&self.interner),
120 [object] => self.registry.refresh(&self.interner, &store, *object)?,
121 many => {
122 return Err(IndexError::Ambiguous {
123 type_name: RegistryChange::type_name(),
124 count: many.len(),
125 });
126 }
127 };
128 evacuated.iter().for_each(|repo| {
129 if let Some(key) = self.interner.repo(repo) {
130 self.collaborators.drop_repo(key);
131 }
132 });
133 self.bump_generation();
134 Ok(())
135 }
136
137 pub fn ensure_collaborators(&self, repo: &RepoDid) -> Result<(), IndexError> {
138 if self.collaborators.is_folded(&self.interner, repo) {
139 return Ok(());
140 }
141 self.refresh_collaborators(repo)
142 }
143
144 pub fn refresh_collaborators(&self, repo: &RepoDid) -> Result<(), IndexError> {
145 let git = self.layout.open(repo)?;
146 let store = CobStore::new(&git);
147 let repo_key = self.interner.intern_repo(repo);
148 match store.list::<CollaboratorsCob>()?.as_slice() {
149 [] => self.collaborators.mark_repo_empty(repo_key),
150 [object] => {
151 self.collaborators
152 .refresh_repo(&self.interner, &store, repo_key, *object)?
153 }
154 many => {
155 return Err(IndexError::Ambiguous {
156 type_name: CollaboratorsChange::type_name(),
157 count: many.len(),
158 });
159 }
160 }
161 self.bump_generation();
162 Ok(())
163 }
164
165 pub fn is_member(&self, did: &AccountDid) -> Resolved<bool> {
166 self.members.contains(&self.interner, did)
167 }
168
169 pub fn member_entries(&self) -> Resolved<Vec<Grant>> {
170 self.members.entries(&self.interner)
171 }
172
173 pub fn is_blocked(&self, did: &AccountDid) -> Resolved<bool> {
174 self.blocklist.contains(&self.interner, did)
175 }
176
177 pub fn blocked_entries(&self) -> Resolved<Vec<Grant>> {
178 self.blocklist.entries(&self.interner)
179 }
180
181 pub fn is_collaborator(&self, repo: &RepoDid, did: &AccountDid) -> Resolved<bool> {
182 self.collaborators.contains(&self.interner, repo, did)
183 }
184
185 pub fn collaborator_entries(&self, repo: &RepoDid) -> Resolved<Vec<Grant>> {
186 self.collaborators.entries(&self.interner, repo)
187 }
188
189 pub fn collaborators_of(&self, repo: &RepoDid) -> Resolved<Vec<AccountDid>> {
190 self.collaborator_entries(repo)
191 .map(|entries| entries.into_iter().map(|grant| grant.subject).collect())
192 }
193
194 pub fn resolve_repo(&self, owner: &OwnerDid, rkey: &RepoRkey) -> Resolved<Option<RepoDid>> {
195 self.registry.resolve(&self.interner, owner, rkey)
196 }
197
198 pub fn owner_of(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>> {
199 self.registry.owner_of(&self.interner, repo)
200 }
201
202 pub fn rkey_of(&self, repo: &RepoDid) -> Resolved<Option<RepoRkey>> {
203 self.registry.rkey_of(&self.interner, repo)
204 }
205
206 pub fn hosted_repos(&self) -> Vec<RepoDid> {
207 self.registry.hosted_repos(&self.interner)
208 }
209
210 pub fn owner_of_key(&self, key: &OfferedKey) -> Resolved<Option<AccountDid>> {
211 self.keys.owner(&self.interner, key)
212 }
213
214 pub fn cache_key(&self, key: OfferedKey, did: &AccountDid) {
215 self.keys.cache(&self.interner, key, did);
216 }
217
218 pub fn coverage(&self) -> IndexCoverage {
219 IndexCoverage {
220 members: self.members.coverage(),
221 blocklist: self.blocklist.coverage(),
222 collaborators: self.collaborators.coverage(),
223 registry: self.registry.coverage(),
224 keys: self.keys.coverage(),
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use knot_cob::{CobHome, CobId, CobStore};
233 use knot_cobs::{Grant, Registration, Removal, Rename, RepoRef};
234 use knot_git::Repo;
235 use knot_runtime::{K256Signer, SeededEntropy};
236 use knot_types::{KnotId, RepoName, UnixSeconds};
237 use tempfile::TempDir;
238
239 fn acc(suffix: &str) -> AccountDid {
240 AccountDid::new(format!("did:plc:{suffix}")).unwrap()
241 }
242
243 fn meta_home() -> CobHome {
244 CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap())
245 }
246
247 fn own(suffix: &str) -> OwnerDid {
248 OwnerDid::new(format!("did:plc:{suffix}")).unwrap()
249 }
250
251 fn repo_did(suffix: &str) -> RepoDid {
252 RepoDid::new(format!("did:plc:{suffix}")).unwrap()
253 }
254
255 fn rkey(value: &str) -> RepoRkey {
256 RepoRkey::new(value).unwrap()
257 }
258
259 fn registration(owner_id: &str, key: &str, repo: &RepoDid, seconds: i64) -> Registration {
260 Registration {
261 owner: own(owner_id),
262 rkey: rkey(key),
263 name: RepoName::new(key).unwrap(),
264 repo: repo.clone(),
265 created_at: at(seconds),
266 }
267 }
268
269 fn at(seconds: i64) -> UnixSeconds {
270 UnixSeconds::new(seconds)
271 }
272
273 fn grant(subject: &str, added_by: &str, seconds: i64) -> Grant {
274 Grant {
275 subject: acc(subject),
276 added_by: acc(added_by),
277 created_at: at(seconds),
278 }
279 }
280
281 struct World {
282 _dir: TempDir,
283 meta_path: PathBuf,
284 layout: Layout,
285 signer: K256Signer,
286 }
287
288 impl World {
289 fn new() -> Self {
290 let dir = tempfile::tempdir().unwrap();
291 let meta_path = dir.path().join("meta");
292 Repo::create(&meta_path).unwrap();
293 let layout = Layout::new(dir.path().join("repos"));
294 let signer = K256Signer::generate(&SeededEntropy::new(1));
295 Self {
296 _dir: dir,
297 meta_path,
298 layout,
299 signer,
300 }
301 }
302
303 fn index(&self) -> Index {
304 Index::new(&self.meta_path, self.layout.clone())
305 }
306
307 fn seed_members(&self) -> CobId {
308 let meta = Repo::open(&self.meta_path).unwrap();
309 let store = CobStore::new(&meta);
310 let created = store
311 .create(
312 &meta_home(),
313 &MembersChange::Add(grant("nel", "nel", 1)),
314 &self.signer,
315 at(1),
316 )
317 .unwrap();
318 store
319 .update(
320 &meta_home(),
321 created.object,
322 &MembersChange::Add(grant("olaren", "nel", 2)),
323 &self.signer,
324 at(2),
325 )
326 .unwrap();
327 created.object
328 }
329
330 fn add_member(&self, object: CobId, subject: &str, seconds: i64) {
331 let meta = Repo::open(&self.meta_path).unwrap();
332 let store = CobStore::new(&meta);
333 store
334 .update(
335 &meta_home(),
336 object,
337 &MembersChange::Add(grant(subject, "nel", seconds)),
338 &self.signer,
339 at(seconds),
340 )
341 .unwrap();
342 }
343
344 fn seed_registry(&self, repo: &RepoDid) -> CobId {
345 let meta = Repo::open(&self.meta_path).unwrap();
346 let store = CobStore::new(&meta);
347 store
348 .create(
349 &meta_home(),
350 &RegistryChange::Register(registration("nel", "anemone", repo, 1)),
351 &self.signer,
352 at(1),
353 )
354 .unwrap()
355 .object
356 }
357
358 fn register_extra(&self, repo: &RepoDid, key: &str, registry: CobId) {
359 let meta = Repo::open(&self.meta_path).unwrap();
360 let store = CobStore::new(&meta);
361 store
362 .update(
363 &meta_home(),
364 registry,
365 &RegistryChange::Register(registration("nel", key, repo, 2)),
366 &self.signer,
367 at(2),
368 )
369 .unwrap();
370 }
371
372 fn seed_collaborator(&self, repo: &RepoDid, subject: &str) -> CobId {
373 let git = self.layout.create(repo).unwrap();
374 let store = CobStore::new(&git);
375 store
376 .create(
377 &CobHome::from(repo),
378 &CollaboratorsChange::Add(grant(subject, "nel", 1)),
379 &self.signer,
380 at(1),
381 )
382 .unwrap()
383 .object
384 }
385
386 fn remove_collaborator(&self, repo: &RepoDid, object: CobId, subject: &str, seconds: i64) {
387 let git = self.layout.open(repo).unwrap();
388 let store = CobStore::new(&git);
389 store
390 .update(
391 &CobHome::from(repo),
392 object,
393 &CollaboratorsChange::Remove(Removal {
394 subject: acc(subject),
395 }),
396 &self.signer,
397 at(seconds),
398 )
399 .unwrap();
400 }
401 }
402
403 #[test]
404 fn rebuild_folds_members_and_registry_and_collaborators_fold_on_access() {
405 let world = World::new();
406 let repo = repo_did("squid");
407 world.seed_members();
408 world.seed_registry(&repo);
409 world.seed_collaborator(&repo, "lyna");
410
411 let index = world.index();
412 index.rebuild().unwrap();
413
414 assert_eq!(index.is_member(&acc("nel")), Resolved::Ready(true));
415 assert_eq!(index.is_member(&acc("olaren")), Resolved::Ready(true));
416 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
417 assert_eq!(
418 index.resolve_repo(&own("nel"), &rkey("anemone")),
419 Resolved::Ready(Some(repo.clone()))
420 );
421 assert_eq!(
422 index.resolve_repo(&own("nel"), &rkey("nautilus")),
423 Resolved::Ready(None)
424 );
425 assert_eq!(
426 index.is_collaborator(&repo, &acc("lyna")),
427 Resolved::Warming,
428 "rebuild does not fold collaborators, so roster reads warming until first access"
429 );
430
431 index.ensure_collaborators(&repo).unwrap();
432 assert_eq!(
433 index.is_collaborator(&repo, &acc("lyna")),
434 Resolved::Ready(true)
435 );
436 assert_eq!(
437 index.is_collaborator(&repo, &acc("bailey")),
438 Resolved::Ready(false)
439 );
440 assert_eq!(
441 index.coverage(),
442 IndexCoverage {
443 members: Coverage::Ready,
444 blocklist: Coverage::Ready,
445 collaborators: Coverage::Ready,
446 registry: Coverage::Ready,
447 keys: Coverage::Ready,
448 }
449 );
450 }
451
452 #[test]
453 fn a_warming_projection_fails_closed() {
454 let world = World::new();
455 world.seed_members();
456 let index = world.index();
457
458 assert_eq!(index.is_member(&acc("nel")), Resolved::Warming);
459 assert_eq!(
460 index.is_collaborator(&repo_did("squid"), &acc("lyna")),
461 Resolved::Warming
462 );
463 assert_eq!(
464 index.resolve_repo(&own("nel"), &rkey("anemone")),
465 Resolved::Warming
466 );
467 assert_eq!(index.coverage().members, Coverage::Warming);
468
469 assert_eq!(
470 index.owner_of_key(&OfferedKey::from_bytes(vec![1, 2, 3])),
471 Resolved::Ready(None),
472 "key cache is operational from boot, never warming"
473 );
474 }
475
476 #[test]
477 fn a_member_added_after_boot_is_delta_applied() {
478 let world = World::new();
479 let members = world.seed_members();
480 let index = world.index();
481 index.rebuild().unwrap();
482 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
483
484 world.add_member(members, "teq", 3);
485 index.refresh_members().unwrap();
486 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(true));
487
488 index.refresh_members().unwrap();
489 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(true));
490 assert_eq!(index.is_member(&acc("nel")), Resolved::Ready(true));
491 }
492
493 #[test]
494 fn owner_of_resolves_a_repo_to_its_registered_owner() {
495 let world = World::new();
496 let repo = repo_did("squid");
497 world.seed_registry(&repo);
498
499 let index = world.index();
500 assert_eq!(
501 index.owner_of(&repo),
502 Resolved::Warming,
503 "repo lookup before rebuild fails closed"
504 );
505
506 index.refresh_registry().unwrap();
507 assert_eq!(
508 index.owner_of(&repo),
509 Resolved::Ready(Some(own("nel"))),
510 "registered repo resolves to owner it was registered under"
511 );
512 assert_eq!(
513 index.owner_of(&repo_did("conch")),
514 Resolved::Ready(None),
515 "unregistered repo has no owner"
516 );
517 }
518
519 #[test]
520 fn a_removed_collaborator_is_gone() {
521 let world = World::new();
522 let repo = repo_did("clam");
523 world.seed_registry(&repo);
524 let object = world.seed_collaborator(&repo, "lyna");
525 world.remove_collaborator(&repo, object, "lyna", 2);
526
527 let index = world.index();
528 index.rebuild().unwrap();
529 index.ensure_collaborators(&repo).unwrap();
530 assert_eq!(
531 index.is_collaborator(&repo, &acc("lyna")),
532 Resolved::Ready(false)
533 );
534 }
535
536 #[test]
537 fn an_unaccessed_repo_is_warming_even_when_a_folded_one_is_ready() {
538 let world = World::new();
539 let repo = repo_did("whelk");
540 world.seed_registry(&repo);
541 world.seed_collaborator(&repo, "lyna");
542
543 let index = world.index();
544 index.rebuild().unwrap();
545 assert_eq!(
546 index.collaborators.coverage(),
547 Coverage::Ready,
548 "collaborators projection is operational from boot"
549 );
550 index.ensure_collaborators(&repo).unwrap();
551 assert_eq!(
552 index.is_collaborator(&repo, &acc("lyna")),
553 Resolved::Ready(true)
554 );
555 assert_eq!(
556 index.is_collaborator(&repo_did("conch"), &acc("lyna")),
557 Resolved::Warming,
558 "repo the index never folded cannot answer, so it fails closed"
559 );
560 }
561
562 #[test]
563 fn rebuild_is_byte_identical_across_restarts() {
564 let world = World::new();
565 let repo = repo_did("squid");
566 world.seed_members();
567 world.seed_registry(&repo);
568 world.seed_collaborator(&repo, "lyna");
569
570 let first = world.index();
571 first.rebuild().unwrap();
572 first.ensure_collaborators(&repo).unwrap();
573 let second = world.index();
574 second.rebuild().unwrap();
575 second.ensure_collaborators(&repo).unwrap();
576
577 ["nel", "olaren", "lyna", "stranger"]
578 .into_iter()
579 .for_each(|subject| {
580 assert_eq!(
581 first.is_member(&acc(subject)),
582 second.is_member(&acc(subject))
583 );
584 assert_eq!(
585 first.is_collaborator(&repo, &acc(subject)),
586 second.is_collaborator(&repo, &acc(subject))
587 );
588 });
589 assert_eq!(
590 first.resolve_repo(&own("nel"), &rkey("anemone")),
591 second.resolve_repo(&own("nel"), &rkey("anemone"))
592 );
593 }
594
595 #[test]
596 fn the_key_cache_round_trips() {
597 let world = World::new();
598 let index = world.index();
599 let key = OfferedKey::from_bytes(vec![9, 9, 9]);
600
601 assert_eq!(index.owner_of_key(&key), Resolved::Ready(None));
602 index.cache_key(key.clone(), &acc("nel"));
603 assert_eq!(index.owner_of_key(&key), Resolved::Ready(Some(acc("nel"))));
604 }
605
606 #[test]
607 fn member_entries_carry_provenance_and_fail_closed_while_warming() {
608 let world = World::new();
609 world.seed_members();
610 let index = world.index();
611 assert_eq!(index.member_entries(), Resolved::Warming);
612
613 index.rebuild().unwrap();
614 assert_eq!(
615 index.member_entries(),
616 Resolved::Ready(vec![grant("nel", "nel", 1), grant("olaren", "nel", 2)])
617 );
618 }
619
620 #[test]
621 fn a_re_added_member_keeps_the_first_provenance() {
622 let world = World::new();
623 let members = world.seed_members();
624 let index = world.index();
625 index.rebuild().unwrap();
626
627 let meta = Repo::open(&world.meta_path).unwrap();
628 let store = CobStore::new(&meta);
629 store
630 .update(
631 &meta_home(),
632 members,
633 &MembersChange::Add(grant("olaren", "teq", 9)),
634 &world.signer,
635 at(9),
636 )
637 .unwrap();
638 index.refresh_members().unwrap();
639
640 assert_eq!(
641 index.member_entries(),
642 Resolved::Ready(vec![grant("nel", "nel", 1), grant("olaren", "nel", 2)]),
643 "duplicate add never rewrites original provenance, matching canonical roster"
644 );
645 }
646
647 #[test]
648 fn collaborator_entries_match_the_canonical_roster() {
649 let world = World::new();
650 let repo = repo_did("squid");
651 world.seed_registry(&repo);
652 let object = world.seed_collaborator(&repo, "lyna");
653 let git = world.layout.open(&repo).unwrap();
654 let store = CobStore::new(&git);
655 store
656 .update(
657 &CobHome::from(&repo),
658 object,
659 &CollaboratorsChange::Add(grant("bailey", "olaren", 2)),
660 &world.signer,
661 at(2),
662 )
663 .unwrap();
664 world.remove_collaborator(&repo, object, "lyna", 3);
665 store
666 .update(
667 &CobHome::from(&repo),
668 object,
669 &CollaboratorsChange::Add(grant("lyna", "teq", 5)),
670 &world.signer,
671 at(5),
672 )
673 .unwrap();
674
675 let index = world.index();
676 index.rebuild().unwrap();
677 index.ensure_collaborators(&repo).unwrap();
678
679 let canonical = store.get::<CollaboratorsCob>(object).unwrap();
680 let expected: Vec<Grant> = canonical
681 .state()
682 .entries()
683 .map(|(subject, entry)| Grant {
684 subject: subject.clone(),
685 added_by: entry.added_by.clone(),
686 created_at: entry.created_at,
687 })
688 .collect();
689 assert_eq!(
690 index.collaborator_entries(&repo),
691 Resolved::Ready(expected),
692 "projected entries disagree with canonical Evaluate fold"
693 );
694 }
695
696 #[test]
697 fn collaborator_entries_fails_closed_while_unfolded() {
698 let world = World::new();
699 world.seed_collaborator(&repo_did("squid"), "lyna");
700 let index = world.index();
701 assert_eq!(
702 index.collaborator_entries(&repo_did("squid")),
703 Resolved::Warming,
704 "roster that has never been folded fails closed"
705 );
706 }
707
708 #[test]
709 fn the_forward_path_serves_a_folded_repo_while_an_unaccessed_one_stays_warming() {
710 let world = World::new();
711 let present = repo_did("squid");
712 let absent = repo_did("kelp");
713 let registry = world.seed_registry(&present);
714 world.register_extra(&absent, "barnacle", registry);
715 world.seed_collaborator(&present, "lyna");
716
717 let index = world.index();
718 index.rebuild().unwrap();
719 index.ensure_collaborators(&present).unwrap();
720
721 assert_eq!(
722 index
723 .collaborator_entries(&present)
724 .map(|grants| grants.len()),
725 Resolved::Ready(1),
726 "folded repo serves its roster"
727 );
728 assert_eq!(
729 index.collaborator_entries(&absent),
730 Resolved::Warming,
731 "registered repo that was never accessed stays fail-closed until folded"
732 );
733 }
734
735 #[test]
736 fn two_objects_of_one_type_are_ambiguous() {
737 let world = World::new();
738 let meta = Repo::open(&world.meta_path).unwrap();
739 let store = CobStore::new(&meta);
740 store
741 .create(
742 &meta_home(),
743 &MembersChange::Add(grant("nel", "nel", 1)),
744 &world.signer,
745 at(1),
746 )
747 .unwrap();
748 store
749 .create(
750 &meta_home(),
751 &MembersChange::Add(grant("olaren", "olaren", 2)),
752 &world.signer,
753 at(2),
754 )
755 .unwrap();
756
757 let index = world.index();
758 assert!(matches!(
759 index.refresh_members(),
760 Err(IndexError::Ambiguous { count: 2, .. })
761 ));
762 }
763
764 #[test]
765 fn a_repo_missing_on_disk_does_not_fail_the_boot_and_isolates_its_fold() {
766 let world = World::new();
767 let present = repo_did("squid");
768 let absent = repo_did("kelp");
769 world.seed_members();
770 let registry = world.seed_registry(&present);
771 world.register_extra(&absent, "barnacle", registry);
772 world.seed_collaborator(&present, "lyna");
773
774 let index = world.index();
775 index
776 .rebuild()
777 .expect("boot folds members and registry only, so missing repo dir never fails it");
778
779 index.ensure_collaborators(&present).unwrap();
780 assert_eq!(
781 index.is_collaborator(&present, &acc("lyna")),
782 Resolved::Ready(true)
783 );
784 assert!(
785 index.ensure_collaborators(&absent).is_err(),
786 "folding repo with no dir on disk fails for that repo alone"
787 );
788 assert_eq!(
789 index.is_collaborator(&absent, &acc("lyna")),
790 Resolved::Warming,
791 "repo the index could not fold stays fail-closed"
792 );
793 }
794
795 #[test]
796 fn concurrent_refreshes_of_distinct_repos_all_land() {
797 use std::sync::Arc;
798
799 let world = World::new();
800 let repos = ["squid", "clam", "whelk", "conch"];
801 repos.iter().for_each(|repo| {
802 world.seed_collaborator(&repo_did(repo), "lyna");
803 });
804
805 let index = Arc::new(world.index());
806 std::thread::scope(|scope| {
807 repos.iter().for_each(|repo| {
808 let index = Arc::clone(&index);
809 let repo = repo_did(repo);
810 scope.spawn(move || index.refresh_collaborators(&repo).unwrap());
811 });
812 });
813
814 repos.iter().for_each(|repo| {
815 assert_eq!(
816 index.is_collaborator(&repo_did(repo), &acc("lyna")),
817 Resolved::Ready(true)
818 );
819 });
820 }
821
822 #[test]
823 fn collaborators_fold_matches_canonical_evaluate() {
824 let world = World::new();
825 let repo = repo_did("squid");
826 let object = world.seed_collaborator(&repo, "lyna");
827 let git = world.layout.open(&repo).unwrap();
828 let store = CobStore::new(&git);
829 store
830 .update(
831 &CobHome::from(&repo),
832 object,
833 &CollaboratorsChange::Add(grant("bailey", "nel", 2)),
834 &world.signer,
835 at(2),
836 )
837 .unwrap();
838 world.remove_collaborator(&repo, object, "lyna", 3);
839
840 let index = world.index();
841 index.refresh_collaborators(&repo).unwrap();
842
843 let canonical = store.get::<CollaboratorsCob>(object).unwrap();
844 let roster = canonical.state();
845 ["lyna", "bailey", "nel"].into_iter().for_each(|who| {
846 assert_eq!(
847 index.is_collaborator(&repo, &acc(who)),
848 Resolved::Ready(roster.contains(&acc(who))),
849 "collaborator fold disagrees with canonical Evaluate for {who}"
850 );
851 });
852 }
853
854 #[test]
855 fn registry_fold_matches_canonical_evaluate() {
856 let world = World::new();
857 let registry = world.seed_registry(&repo_did("squid"));
858 world.register_extra(&repo_did("clam"), "barnacle", registry);
859
860 let meta = Repo::open(&world.meta_path).unwrap();
861 let store = CobStore::new(&meta);
862 store
863 .update(
864 &meta_home(),
865 registry,
866 &RegistryChange::Rename(Rename {
867 owner: own("nel"),
868 rkey: rkey("nautilus"),
869 name: RepoName::new("nautilus").unwrap(),
870 repo: repo_did("squid"),
871 }),
872 &world.signer,
873 at(3),
874 )
875 .unwrap();
876 store
877 .update(
878 &meta_home(),
879 registry,
880 &RegistryChange::Register(Registration {
881 owner: own("nel"),
882 rkey: rkey("anemone"),
883 name: RepoName::new("anemone").unwrap(),
884 repo: repo_did("whelk"),
885 created_at: at(4),
886 }),
887 &world.signer,
888 at(4),
889 )
890 .unwrap();
891 store
892 .update(
893 &meta_home(),
894 registry,
895 &RegistryChange::Deregister(RepoRef {
896 owner: own("nel"),
897 rkey: rkey("barnacle"),
898 }),
899 &world.signer,
900 at(5),
901 )
902 .unwrap();
903
904 let index = world.index();
905 index.refresh_registry().unwrap();
906
907 let canonical = store.get::<RepoRegistryCob>(registry).unwrap();
908 let reg = canonical.state();
909 [
910 ("nel", "anemone"),
911 ("nel", "barnacle"),
912 ("nel", "nautilus"),
913 ("nel", "kelp"),
914 ]
915 .into_iter()
916 .for_each(|(owner, key)| {
917 assert_eq!(
918 index.resolve_repo(&own(owner), &rkey(key)),
919 Resolved::Ready(reg.resolve(&own(owner), &rkey(key)).cloned()),
920 "registry fold disagrees with canonical Evaluate for {owner}/{key}"
921 );
922 });
923 [repo_did("squid"), repo_did("clam"), repo_did("whelk")]
924 .iter()
925 .for_each(|repo| {
926 assert_eq!(
927 index.owner_of(repo),
928 Resolved::Ready(reg.owner_of(repo)),
929 "owner fold disagrees with canonical Evaluate for {repo}"
930 );
931 assert_eq!(
932 index.rkey_of(repo),
933 Resolved::Ready(reg.record_of(repo).map(|record| record.rkey.clone())),
934 "canonical rkey fold disagrees with canonical Evaluate for {repo}"
935 );
936 });
937 }
938
939 #[test]
940 fn rebuild_aborts_on_a_broken_meta_cob() {
941 let world = World::new();
942 let meta = Repo::open(&world.meta_path).unwrap();
943 let store = CobStore::new(&meta);
944 store
945 .create(
946 &meta_home(),
947 &MembersChange::Add(grant("nel", "nel", 1)),
948 &world.signer,
949 at(1),
950 )
951 .unwrap();
952 store
953 .create(
954 &meta_home(),
955 &MembersChange::Add(grant("olaren", "olaren", 2)),
956 &world.signer,
957 at(2),
958 )
959 .unwrap();
960
961 let index = world.index();
962 assert!(
963 matches!(index.rebuild(), Err(IndexError::Ambiguous { .. })),
964 "broken meta COB fails whole boot instead of reporting partial one"
965 );
966 }
967
968 #[test]
969 fn a_refresh_is_eventually_consistent_not_an_atomic_snapshot() {
970 use std::sync::Arc;
971 use std::sync::atomic::{AtomicBool, Ordering};
972
973 let world = World::new();
974 let members = world.seed_members();
975 let index = Arc::new(world.index());
976 index.rebuild().unwrap();
977
978 (0..32).for_each(|i| world.add_member(members, &format!("m{i}"), 10 + i as i64));
979
980 let done = Arc::new(AtomicBool::new(false));
981 std::thread::scope(|scope| {
982 let writer = Arc::clone(&index);
983 let writer_done = Arc::clone(&done);
984 scope.spawn(move || {
985 writer.refresh_members().unwrap();
986 writer_done.store(true, Ordering::Release);
987 });
988
989 let reader = Arc::clone(&index);
990 let reader_done = Arc::clone(&done);
991 scope.spawn(move || {
992 while !reader_done.load(Ordering::Acquire) {
993 assert_eq!(
994 reader.is_member(&acc("nel")),
995 Resolved::Ready(true),
996 "stable member stays visible and read never blocks on writer"
997 );
998 assert!(
999 !reader.is_member(&acc("m0")).is_warming(),
1000 "already-ready projection serves reads mid-refresh, it never re-warms"
1001 );
1002 }
1003 });
1004 });
1005
1006 (0..32).for_each(|i| {
1007 assert_eq!(
1008 index.is_member(&acc(&format!("m{i}"))),
1009 Resolved::Ready(true),
1010 "once writer returns, whole delta has converged"
1011 );
1012 });
1013 }
1014}