diff --git a/knot2/crates/knot-index/tests/common/mod.rs b/knot2/crates/knot-index/tests/common/mod.rs index bebf25d98..a6f59ea9f 100644 --- a/knot2/crates/knot-index/tests/common/mod.rs +++ b/knot2/crates/knot-index/tests/common/mod.rs @@ -2,12 +2,22 @@ use std::path::PathBuf; -use knot_cob::{ChangePayload, CobHome, CobId, CobStore}; -use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange, Removal}; +use knot_cob::{ + Approved, Authored, ChangePayload, CobHome, CobId, CobStore, KnotAuthored, Materialized, + Record, WriteContext, +}; +use knot_cobs::{ + CollaboratorsChange, Creation, Editing, Erasure, Grant, MembersChange, Registration, + RegistryChange, Removal, +}; use knot_git::{Layout, Repo}; use knot_index::{Index, KeyBudget}; use knot_runtime::{K256Signer, SeededEntropy}; -use knot_types::{AccountDid, KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; +use knot_types::{ + AccountDid, Blocked, ContributionPermission, ContributionPolicy, KnotId, OwnerDid, + RecordAddress, RecordBody, RecordCid, RecordCollection, RecordRkey, RepoDid, RepoName, + RepoRkey, RepoRole, SubjectNumber, UnixSeconds, +}; use tempfile::TempDir; pub fn acc(suffix: &str) -> AccountDid { @@ -49,6 +59,7 @@ pub fn registration(owner_id: &str, key: &str, repo: &RepoDid, seconds: i64) -> name: RepoName::new(key).unwrap(), repo: repo.clone(), created_at: at(seconds), + policy: knot_types::ContributionPolicy::default(), } } @@ -65,6 +76,7 @@ pub fn named_registration( name: RepoName::new(display).unwrap(), repo: repo.clone(), created_at: at(seconds), + policy: knot_types::ContributionPolicy::default(), } } @@ -102,14 +114,14 @@ impl World { Index::with_key_budget(&self.meta_path, self.layout.clone(), budget) } - pub fn on_meta(&self, object: CobId, change: &P, t: i64) { + pub fn on_meta(&self, object: CobId, change: &P, t: i64) { let meta = Repo::open(&self.meta_path).unwrap(); CobStore::new(&meta) .update(&meta_home(), object, change, &self.signer, at(t)) .unwrap(); } - pub fn on_repo(&self, repo: &RepoDid, object: CobId, change: &P, t: i64) { + pub fn on_repo(&self, repo: &RepoDid, object: CobId, change: &P, t: i64) { let git = self.layout.open(repo).unwrap(); CobStore::new(&git) .update(&CobHome::from(repo), object, change, &self.signer, at(t)) @@ -228,4 +240,177 @@ impl World { ) .unwrap(); } + + pub fn open_social( + &self, + repo: &RepoDid, + change: &NoteChange, + body: RecordBody, + seconds: i64, + ) -> CobId { + self.open_social_with(repo, change, Record::version(body), seconds) + } + + pub fn open_social_with( + &self, + repo: &RepoDid, + change: &NoteChange, + record: Record, + seconds: i64, + ) -> CobId { + let git = self + .layout + .open(repo) + .unwrap_or_else(|_| self.layout.create(repo).unwrap()); + let author = change.author().clone(); + let cx = as_author(&author, repo); + CobStore::new(&git) + .create_approved::( + &CobHome::from(repo), + &Approved::signed_by( + &cx, + Materialized { + change: change.clone(), + record, + }, + ) + .unwrap(), + &self.signer, + at(seconds), + ) + .unwrap() + .object + } + + pub fn append_social( + &self, + repo: &RepoDid, + object: CobId, + change: &NoteChange, + record: Record, + seconds: i64, + ) { + let git = self.layout.open(repo).unwrap(); + let author = change.author().clone(); + let cx = as_author(&author, repo); + CobStore::new(&git) + .append_approved::( + &CobHome::from(repo), + object, + &Approved::signed_by( + &cx, + Materialized { + change: change.clone(), + record, + }, + ) + .unwrap(), + &self.signer, + at(seconds), + ) + .unwrap(); + } +} + +fn as_author<'a>(author: &'a AccountDid, repo: &'a RepoDid) -> WriteContext<'a> { + WriteContext::new( + author, + repo, + ContributionPermission::new(Blocked::No, RepoRole::Stranger, ContributionPolicy::Anyone), + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NoteBody { + pub text: String, +} + +knot_cobs::social_cob! { + body = NoteBody, + change = NoteChange, + cob = NoteCob, + collection = "sh.tangled.test.note", +} + +pub fn note_collection() -> RecordCollection { + NoteCob::collection() +} + +pub fn note_address(tid: &str) -> RecordAddress { + RecordAddress::new(note_collection(), RecordRkey::new(tid).unwrap()) +} + +pub fn note_type_name() -> knot_types::TypeName { + NoteChange::type_name() +} + +pub fn note_record(text: &str) -> (RecordBody, RecordCid) { + let body = RecordBody::new( + serde_ipld_dagcbor::to_vec(&NoteBody { + text: text.to_owned(), + }) + .unwrap(), + ) + .unwrap(); + let cid = body.cid(); + (body, cid) +} + +pub fn opening( + tid: &str, + author: &str, + number: u32, + parent: Option, + text: &str, + seconds: i64, +) -> (NoteChange, RecordBody) { + let (bytes, produces) = note_record(text); + ( + NoteChange::Open { + creation: Creation { + address: note_address(tid), + author: acc(author), + number: SubjectNumber::new(number).expect("Subject is numbered from one"), + parent, + created_at: at(seconds), + produces, + }, + body: NoteBody { + text: text.to_owned(), + }, + }, + bytes, + ) +} +pub fn editing( + author: &str, + from: RecordCid, + text: &str, + seconds: i64, +) -> (NoteChange, RecordBody) { + let (bytes, produces) = note_record(text); + ( + NoteChange::Edit { + editing: Editing { + author: acc(author), + from, + produces, + edited_at: at(seconds), + }, + body: NoteBody { + text: text.to_owned(), + }, + }, + bytes, + ) +} + +pub fn erasing(author: &str, from: RecordCid, seconds: i64) -> NoteChange { + NoteChange::Erase { + erasure: Erasure { + author: acc(author), + from, + erased_at: at(seconds), + }, + } } diff --git a/knot2/crates/knot-index/tests/lifecycle.rs b/knot2/crates/knot-index/tests/lifecycle.rs index eed036013..196b20059 100644 --- a/knot2/crates/knot-index/tests/lifecycle.rs +++ b/knot2/crates/knot-index/tests/lifecycle.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use knot_cob::{CobHome, CobStore}; use knot_cobs::{Accept, CollaboratorsChange, CollaboratorsCob, Invite, MembersChange, Offer}; use knot_git::Repo; -use knot_index::{Coverage, Entry, IndexCoverage, IndexError, OfferedKey, Resolved, Standing}; +use knot_index::{Coverage, Entry, EntryState, IndexCoverage, IndexError, OfferedKey, Resolved}; use knot_types::AccountDid; mod common; @@ -38,7 +38,7 @@ fn rebuild_folds_members_and_registry_and_collaborators_fold_on_access() { assert_eq!( index.effective_collaborator(&repo, &acc("lyna")), Resolved::Warming, - "rebuild doesn't fold collaborators, so roster reads warming until first access" + "roll reads warming until first access, rebuild doesn't fold collaborators" ); index.ensure_collaborators(&repo).unwrap(); @@ -78,7 +78,7 @@ fn every_accessor_fails_closed_while_warming() { assert_eq!( index.collaborator_entries(&repo_did("squid")), Resolved::Warming, - "roster that has never been folded fails closed" + "Roll that has never been folded fails closed" ); assert_eq!( index.resolve_repo(&own("nel"), &rkey("anemone")), @@ -148,12 +148,12 @@ fn a_re_added_member_keeps_the_first_provenance() { (acc("nel"), asserted("nel", 1)), (acc("olaren"), asserted("nel", 2)), ]), - "duplicate add never rewrites original provenance, matching canonical roster" + "duplicate add never rewrites original provenance, matching canonical roll" ); } #[test] -fn collaborator_entries_match_the_canonical_roster() { +fn collaborator_entries_match_the_canonical_roll() { let world = World::new(); let repo = repo_did("squid"); world.seed_registry(&repo); @@ -198,7 +198,7 @@ fn collaborator_entries_match_the_canonical_roster() { } #[test] -fn invited_subject_is_on_roster_and_gains_access_when_accept_lands() { +fn invited_subject_is_on_the_roll_and_gains_access_when_the_accept_arrives() { let world = World::new(); let members = world.seed_members(); let repo = repo_did("squid"); @@ -240,9 +240,9 @@ fn invited_subject_is_on_roster_and_gains_access_when_accept_lands() { }; assert_eq!( - index.member_standing(&acc("teq")), - Resolved::Ready(Some(Standing::Invited)), - "an invite is an entry on the roster with Standing::Invited" + index.member_state(&acc("teq")), + Resolved::Ready(Some(EntryState::Invited)), + "invite appears on roll as EntryState::Invited" ); assert_eq!( acl(), @@ -298,7 +298,7 @@ fn a_folded_repo_serves_while_unaccessed_repos_stay_warming() { .collaborator_entries(&present) .map(|entries| entries.len()), Resolved::Ready(1), - "folded repo serves its roster" + "folded repo serves its roll" ); assert_eq!( index.collaborator_entries(&absent), diff --git a/knot2/crates/knot-index/tests/meta_repo.rs b/knot2/crates/knot-index/tests/meta_repo.rs index 74debd173..344426ee7 100644 --- a/knot2/crates/knot-index/tests/meta_repo.rs +++ b/knot2/crates/knot-index/tests/meta_repo.rs @@ -45,6 +45,7 @@ fn the_meta_repo_round_trips_every_projection_from_git() { name: RepoName::new("anemone").unwrap(), repo: repo.clone(), created_at: at(1), + policy: knot_types::ContributionPolicy::default(), }), &signer, at(1), diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index 33e022f82..31f543507 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -1,19 +1,20 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use knot_cob::{ChangePayload, CobHome, CobId, CobStore}; +use knot_cob::{ChangePayload, CobHome, CobId, CobStore, KnotAuthored, Record}; use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef}; use knot_git::{RefUpdate, Repo}; use knot_index::{ Coverage, HostedCoverage, IndexError, KeptAccounts, KeyBudget, KeyRecord, KeyReprieve, - KeyReprieved, KeyTtl, OfferedKey, Resolved, SweepFloor, + KeyReprieved, KeyTtl, OfferedKey, Resolved, SocialResidency, SweepInterval, }; -use knot_types::{ClonePath, RefName, RepoName}; +use knot_types::{ClonePath, RecordAddress, RefName, RepoDid, RepoName, SubjectNumber}; use serde::{Deserialize, Serialize}; mod common; use common::{ - World, acc, at, grant, meta_home, named_registration, own, registration, repo_did, rkey, + World, acc, at, editing, erasing, grant, meta_home, named_registration, note_address, + note_collection, note_record, note_type_name, opening, own, registration, repo_did, rkey, }; #[derive(Serialize, Deserialize)] @@ -24,6 +25,7 @@ enum BadMembers { impl ChangePayload for BadMembers { const TYPE: &'static str = "sh.tangled.knot.member"; } +impl KnotAuthored for BadMembers {} #[test] fn a_concurrent_reader_never_sees_a_net_absent_subject() { @@ -94,7 +96,7 @@ fn a_concurrent_reader_never_sees_a_net_absent_subject() { } #[test] -fn a_concurrent_reader_never_sees_a_collaborator_roster_emptied_mid_refresh() { +fn a_concurrent_reader_never_sees_a_collaborator_roll_emptied_mid_refresh() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); @@ -149,7 +151,7 @@ fn a_concurrent_reader_never_sees_a_collaborator_roster_emptied_mid_refresh() { assert!( !leaked.load(Ordering::Acquire), "in-place mem::take runs under per-repo lock, so anchor present in both \ - pre- and post-refresh roster is never observed absent or warming mid-refresh" + pre- and post-refresh roll is never observed absent or warming mid-refresh" ); assert_eq!( index.effective_collaborator(&repo, &acc("anchor")), @@ -162,7 +164,7 @@ fn cob_ref(type_name: &str, object: CobId) -> RefName { } #[test] -fn a_diverged_collaborators_tip_purges_the_roster_instead_of_serving_it_stale() { +fn a_diverged_collaborators_tip_purges_the_roll_instead_of_serving_it_stale() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); @@ -207,8 +209,8 @@ fn a_diverged_collaborators_tip_purges_the_roster_instead_of_serving_it_stale() assert_eq!( index.effective_collaborator(&repo, &acc("lyna")), Resolved::Warming, - "diverged COB tip purges roster and fails closed, it doesn't serve \ - pre-divergence collaborators" + "diverged COB tip purges roll and fails closed, it doesn't serve \ + pre-divergence collaborators" ); } @@ -573,7 +575,7 @@ fn recording_and_retaining_track_who_still_publishes_each_key() { .publisher_among(&[acc("cuttle")], &key(1), at(0)), Some(acc("cuttle")), "a deploy key two accounts publish must answer for the candidate that may push here, \ - whichever of them the knot happened to read first" + whichever of them the knot happened to read first" ); assert_eq!( index.keys().publisher_among(&[acc("teq")], &key(1), at(0)), @@ -591,7 +593,7 @@ fn recording_and_retaining_track_who_still_publishes_each_key() { index.owner_of_key(&key(1), at(0)), Resolved::Ready(Some(acc("nel"))), "one account leaving the grant set mustn't take a shared deploy key away from the \ - account that still publishes it" + account that still publishes it" ); index.keys().record(&acc("nel"), vec![key(2)], hour(0)); @@ -621,17 +623,17 @@ fn a_full_budget_reports_unheld_readings_and_then_refuses_to_grow_at_all() { index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)), KeyRecord::Unheld, "a budget too small for another account's keys still has room to record that the \ - knot read the account" + knot read the account" ); assert!( index.keys().any_unheld(), "a reading the budget couldn't fit means an unknown key may still belong to a \ - candidate, so the handshake must defer to the push check instead of refusing" + candidate, so the handshake must defer to the push check instead of refusing" ); assert!( !index.keys().is_fresh(&acc("cuttle"), at(0)), "the refused account stays stale, so the fill keeps reporting the set incomplete and \ - the knot keeps checking its pushes against its PDS" + the knot keeps checking its pushes against its PDS" ); assert_eq!( index.owner_of_key(&fat(2), at(0)), @@ -678,7 +680,7 @@ fn a_full_budget_reports_unheld_readings_and_then_refuses_to_grow_at_all() { assert!( !index.keys().any_unheld(), "an account the knot gave up rereading is recorded with an empty key set, so its \ - unheld reading mustn't keep the handshake open" + unheld reading mustn't keep the handshake open" ); } @@ -703,12 +705,12 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo work.suspected.len(), 1, "a key the accounts on file don't publish is the only evidence the knot gets that an \ - account published a new key, so the next pass must reread rather than wait out the ttl" + account published a new key, so the next pass must reread rather than wait out the ttl" ); assert!( work.due.is_empty(), "a reread a stranger asked for must stay separate from the reads coverage waits on, or \ - the knot spends a whole pass on it without renewing anything" + the knot spends a whole pass on it without renewing anything" ); assert!( matches!(index.keys().work(at(100), sweep()), Resolved::Ready(work) if work.suspected.is_empty()), @@ -719,12 +721,12 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo assert!( matches!(index.keys().work(at(159), sweep()), Resolved::Ready(work) if work.suspected.is_empty()), "a stranger can offer an unsigned key for free, so a knot that swept a moment ago \ - mustn't spend another read at every PDS in the grant set" + mustn't spend another read at every PDS in the grant set" ); assert!( matches!(index.keys().work(at(160), sweep()), Resolved::Ready(work) if work.suspected.len() == 1), "the miss the floor delayed is honored once the floor has passed, so a key published \ - between passes is still picked up" + between passes is still picked up" ); assert!( @@ -738,13 +740,13 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo work.due.len(), 1, "the fill renews at the halfway mark, so a pass no longer than the lease it is working \ - to finishes before anything expires" + to finishes before anything expires" ); assert!( work.complete, "the lease the account still has answers for it, so a knot busy renewing keeps \ - refusing keys the accounts on file don't publish instead of opening the handshake \ - to every stranger" + refusing keys the accounts on file don't publish instead of opening the handshake \ + to every stranger" ); } @@ -767,7 +769,7 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() work.hosted, HostedCoverage::Partial { unread: 1 }, "the pass must know it read less than the whole roll, or it evicts the accounts it \ - couldn't enumerate" + couldn't enumerate" ); assert!( !work.due.is_empty(), @@ -776,7 +778,7 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() assert!( !work.complete, "a partial roll mustn't let the fill call the set complete, or the knot refuses a \ - pusher whose repo it never managed to read" + pusher whose repo it never managed to read" ); assert_eq!( @@ -791,7 +793,7 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() work.hosted, HostedCoverage::Whole, "a repo the knot can't open won't serve a push, so counting it as unread would leave \ - the set warming and the handshake open to every stranger for good" + the set warming and the handshake open to every stranger for good" ); assert_eq!( work.pushers.as_slice(), @@ -805,7 +807,7 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() assert!( pushers_complete(&index, at(0)), "with the one readable repo's owner on file the set is complete, so the knot can go \ - back to refusing keys the accounts on file don't publish" + back to refusing keys the accounts on file don't publish" ); } @@ -828,7 +830,7 @@ fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() { index.keys().coverage(), Coverage::Warming, "the knot mustn't check a grant it has never read keys for against a set it calls \ - complete, or a new collaborator is refused until the next fill pass" + complete, or a new collaborator is refused until the next fill pass" ); let Resolved::Ready(rereading) = index.keys().work(at(0), sweep()) else { @@ -841,7 +843,7 @@ fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() { index.keys().coverage(), Coverage::Warming, "a pass claims the grant set it read, so a grant written while it was reading keys \ - mustn't count as covered, or whoever it grants is refused at the handshake" + mustn't count as covered, or whoever it grants is refused at the handshake" ); } @@ -870,7 +872,7 @@ fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_co assert!( pushers_complete(&index, at(0)), "a full budget mustn't leave the set warming for good, or the knot spends the rest of \ - its life accepting every key offered to it" + its life accepting every key offered to it" ); let Resolved::Ready(later) = index.keys().work(at(3_601), sweep()) else { panic!("the registry is folded, so the knot knows who may push"); @@ -878,8 +880,8 @@ fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_co assert!( later.due.as_slice().contains(&acc("cuttle")), "the knot tries the account again once the lease runs out, so a budget that frees up \ - starts keeping its keys. Every pass until then costs one read rather than a reread \ - of the whole grant set" + starts keeping its keys. Every pass until then costs one read rather than a reread \ + of the whole grant set" ); } @@ -903,13 +905,13 @@ fn a_key_read_outside_its_lease_stops_answering_for_its_publisher() { index.owner_of_key(&key, at(3_601)), Resolved::Ready(None), "past the lease the knot no longer knows the account publishes the key, so a key \ - revoked while the fill was behind mustn't keep clearing the handshake" + revoked while the fill was behind mustn't keep clearing the handshake" ); assert_eq!( index.keys().publisher_among(&[acc("nel")], &key, at(3_601)), None, "a push check is bound by the same lease, or a key revoked while the fill was behind \ - keeps authorizing pushes" + keeps authorizing pushes" ); } @@ -983,7 +985,7 @@ fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() { assert!( index.keys().is_fresh(&acc("nel"), at(3_599)), "a reread the knot attempted early and couldn't finish mustn't cut the lease it already \ - has down to one retry, or a flaky PDS multiplies what the knot reads from it" + has down to one retry, or a flaky PDS multiplies what the knot reads from it" ); assert_eq!( @@ -995,7 +997,7 @@ fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() { assert!( pushers_complete(&index, at(4_000)), "the reprieve keeps the keys the knot last read, so one unreachable PDS mustn't reopen \ - the knot to every offered key" + the knot to every offered key" ); assert_eq!( index.owner_of_key(&key, at(4_000)), @@ -1044,7 +1046,7 @@ fn an_account_the_knot_never_reads_stops_delaying_the_whole_set() { assert!( !pushers_complete(&index, at(0)), "an unread account mustn't count toward a complete set, or the knot refuses the keys \ - it has yet to fetch" + it has yet to fetch" ); assert!( matches!(index.keys().work(at(299), sweep()), Resolved::Ready(work) if work.due.is_empty()), @@ -1061,12 +1063,12 @@ fn an_account_the_knot_never_reads_stops_delaying_the_whole_set() { .reprieve(&acc("nel"), at(21_601), grace(), hour(21_601)), KeyReprieved::Exhausted, "the reprieve budget runs from the first failure, so a PDS that never answers stops \ - being a pending read" + being a pending read" ); assert!( pushers_complete(&index, at(21_601)), "one dead account mustn't keep the set warming forever, or the knot spends its whole \ - life accepting every key offered to it" + life accepting every key offered to it" ); assert_eq!( index @@ -1110,7 +1112,7 @@ fn concurrent_reads_of_one_account_never_leave_a_key_answering_for_a_set_it_left reverse, Resolved::Ready(forward), "a key the account no longer publishes must stop answering for it, or two reads \ - arriving at once leave a revoked key authorizing pushes for good" + arriving at once leave a revoked key authorizing pushes for good" ); }); }); @@ -1135,8 +1137,8 @@ fn hour(from: i64) -> knot_index::KeyLease { KeyTtl::from_secs(3_600).lease_from(at(from)) } -fn sweep() -> SweepFloor { - SweepFloor::from_secs(60) +fn sweep() -> SweepInterval { + SweepInterval::from_secs(60) } fn pushers_complete(index: &knot_index::Index, now: knot_types::UnixSeconds) -> bool { @@ -1280,7 +1282,7 @@ fn a_name_two_repos_share_resolves_to_the_older_registration() { index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(first.clone())), "a contested name resolves to whichever repo registered first, ordered by \ - created_at so a cold seed and an incremental replay agree" + created_at so a cold seed and an incremental replay agree" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), @@ -1356,7 +1358,7 @@ fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(holder)), "a rename keeps the repo's original created_at, so renaming onto a name \ - another repo registered earlier cannot take it" + another repo registered earlier can't take it" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("uni")), @@ -1638,7 +1640,7 @@ fn a_name_differing_from_its_record_key_only_by_case_resolves() { index.resolve_clone_path(&own("nel"), &path("Runic_lang")), Resolved::Ready(Some(repo.clone())), "the appview lowercases the record key but keeps the display name's case, \ - so the mixed-case path resolves by name" + so the mixed-case path resolves by name" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("runic_lang")), @@ -1646,3 +1648,471 @@ fn a_name_differing_from_its_record_key_only_by_case_resolves() { "the lowercased record key still resolves" ); } + +fn kelp_note() -> (World, RepoDid, CobId) { + let world = World::new(); + let repo = repo_did("squid"); + let (issue, record) = opening("3lubrptx57d22", "nel", 1, None, "kelp", 10); + let object = world.open_social(&repo, &issue, record, 10); + (world, repo, object) +} + +fn folded_kelp() -> (World, RepoDid, CobId, knot_index::Index) { + let (world, repo, object) = kelp_note(); + let index = world.index(); + index.refresh_social(&repo).unwrap(); + (world, repo, object, index) +} + +fn assert_projections_agree( + incremental: &knot_index::Index, + cold: &knot_index::Index, + repo: &RepoDid, + objects: &[CobId], + addresses: &[RecordAddress], +) { + assert_eq!( + incremental.social_of_kind(repo, ¬e_collection()), + cold.social_of_kind(repo, ¬e_collection()), + "Incremental fold and cold fold disagree about which objects exist" + ); + assert_eq!( + incremental.social_structure(repo), + cold.social_structure(repo), + "bytes repository has are the same, whichever way projection got there" + ); + assert_eq!( + incremental.next_subject_number(repo, ¬e_collection()), + cold.next_subject_number(repo, ¬e_collection()) + ); + objects.iter().for_each(|object| { + assert_eq!( + incremental.social_address_of(repo, *object), + cold.social_address_of(repo, *object) + ); + assert_eq!( + incremental.social_version_of(repo, *object), + cold.social_version_of(repo, *object) + ); + }); + addresses.iter().for_each(|at| { + assert_eq!( + incremental.social_object_at(repo, at), + cold.social_object_at(repo, at), + "incremental fold, and cold fold, disagree about which object answers at {at}" + ); + assert_eq!( + incremental.social_children_of(repo, at), + cold.social_children_of(repo, at), + "Incremental fold and cold fold disagree about children of {at}" + ); + }); +} + +#[test] +fn a_social_projection_answers_one_repo_by_address_by_kind_and_by_parent() { + let (world, repo, issue_object) = kelp_note(); + let (second, second_record) = opening("3lubrptx57d33", "olaren", 2, None, "urchin", 20); + let second_object = world.open_social(&repo, &second, second_record, 20); + let (state, state_record) = opening( + "3lubrptx57d44", + "nel", + 1, + Some(note_address("3lubrptx57d22")), + "closed", + 30, + ); + let state_object = world.open_social(&repo, &state, state_record, 30); + + let index = world.index(); + index.refresh_social(&repo).unwrap(); + + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Ready(Some(issue_object)), + "Rkey resolves to object that materializes there" + ); + assert_eq!( + index.social_address_of(&repo, issue_object), + Resolved::Ready(Some(note_address("3lubrptx57d22"))), + "and object resolves back to its address" + ); + assert_eq!( + index.social_of_kind(&repo, ¬e_collection()), + Resolved::Ready(vec![issue_object, second_object, state_object]), + "Kind reads in createdAt order" + ); + assert_eq!( + index.social_children_of(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Ready(vec![state_object]), + "child names its parent's address, and parent reads its children back" + ); + assert_eq!( + index.social_latest_child(&repo, ¬e_address("3lubrptx57d22"), ¬e_collection()), + Resolved::Ready(Some(note_address("3lubrptx57d44"))), + "Latest transition is the one fold's order puts last" + ); + assert_eq!( + index.social_author_of(&repo, issue_object), + Resolved::Ready(Some(acc("nel"))), + "Author is whoever opened the object" + ); + assert_eq!( + index.social_children_of(&repo, ¬e_address("3lubrptx57d33")), + Resolved::Ready(Vec::new()), + "Object nobody answered has no children" + ); +} + +#[test] +fn a_repo_nobody_touched_stays_warming_and_a_write_reaches_the_answer() { + let (world, repo, object) = kelp_note(); + let index = world.index(); + + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Warming, + "unfolded repository fails closed, turning away write that reads it" + ); + assert_eq!( + index.next_subject_number(&repo, ¬e_collection()), + Resolved::Warming + ); + + index.ensure_social(&repo).unwrap(); + assert_eq!( + index.next_subject_number(&repo, ¬e_collection()), + Resolved::Ready(SubjectNumber::new(2)), + "Next number is one past the highest the projection has" + ); + + let (edit, edited_record) = editing("nel", note_record("kelp").1, "kelp forest", 20); + world.append_social(&repo, object, &edit, Record::version(edited_record), 20); + index.refresh_social(&repo).unwrap(); + assert_eq!( + index.social_version_of(&repo, object), + Resolved::Ready(Some(note_record("kelp forest").1)), + "Projection follows version latest change produced" + ); + + world.append_social( + &repo, + object, + &erasing("nel", note_record("kelp forest").1, 30), + Record::Tombstone, + 30, + ); + index.refresh_social(&repo).unwrap(); + assert_eq!( + index.social_version_of(&repo, object), + Resolved::Ready(None), + "Erased object doesn't have version" + ); + assert_eq!( + index.next_subject_number(&repo, ¬e_collection()), + Resolved::Ready(SubjectNumber::new(2)), + "Deletion leaves its number spent, as every forge does" + ); +} + +#[test] +fn a_social_rebuild_from_git_answers_what_the_incremental_fold_answers() { + let (world, repo, object) = kelp_note(); + let (untouched, untouched_record) = opening("3lubrptx57d33", "olaren", 2, None, "urchin", 15); + let untouched_object = world.open_social(&repo, &untouched, untouched_record, 15); + let parent = note_address("3lubrptx57d22"); + let (reply, reply_record) = opening( + "3lubrptx57d44", + "olaren", + 3, + Some(parent.clone()), + "reply", + 16, + ); + let reply_object = world.open_social(&repo, &reply, reply_record, 16); + + let replayed = world.index(); + replayed.refresh_social(&repo).unwrap(); + let (edit, edited_record) = editing("nel", note_record("kelp").1, "kelp forest", 20); + world.append_social(&repo, object, &edit, Record::version(edited_record), 20); + replayed + .refresh_social_object(&repo, ¬e_type_name(), object) + .unwrap(); + + let cold = world.index(); + cold.refresh_social(&repo).unwrap(); + + assert_projections_agree( + &replayed, + &cold, + &repo, + &[object, untouched_object, reply_object], + &[ + note_address("3lubrptx57d22"), + note_address("3lubrptx57d33"), + note_address("3lubrptx57d44"), + ], + ); + assert_eq!( + cold.social_children_of(&repo, &parent), + Resolved::Ready(vec![reply_object]), + "and child index lists the reply under the object it answered" + ); +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "op", content = "data", rename_all = "snake_case")] +enum BadNote { + Explode(u8), +} +impl ChangePayload for BadNote { + const TYPE: &'static str = "sh.tangled.test.note"; +} +impl KnotAuthored for BadNote {} + +#[test] +fn a_social_change_the_projection_cant_read_answers_warming_rather_than_freeing_its_address() { + let (world, repo, object, index) = folded_kelp(); + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Ready(Some(object)) + ); + + world.on_repo(&repo, object, &BadNote::Explode(0), 11); + assert!(matches!( + index.refresh_social(&repo), + Err(IndexError::Decode { .. }) + )); + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Warming, + "object projection can't read, whole repository goes unanswered, and nobody else gets handed address it claimed" + ); + assert_eq!( + index.next_subject_number(&repo, ¬e_collection()), + Resolved::Warming + ); +} + +#[test] +fn a_social_projection_past_its_residency_forgets_the_coldest_repository() { + let world = World::new(); + let repos: Vec<_> = ["squid", "anemone", "barnacle"] + .into_iter() + .map(repo_did) + .collect(); + repos.iter().for_each(|repo| { + let (issue, record) = opening("3lubrptx57d22", "nel", 1, None, "kelp", 10); + world.open_social(repo, &issue, record, 10); + }); + let index = world.index().with_social_residency(SocialResidency::new(2)); + repos + .iter() + .for_each(|repo| index.ensure_social(repo).unwrap()); + + assert_eq!( + index.social_of_kind(&repos[0], ¬e_collection()), + Resolved::Warming, + "Repository folded longest ago made room for newcomer" + ); + assert!(matches!( + index.social_of_kind(&repos[2], ¬e_collection()), + Resolved::Ready(_) + )); + index.ensure_social(&repos[0]).unwrap(); + assert!( + matches!( + index.social_of_kind(&repos[0], ¬e_collection()), + Resolved::Ready(_) + ), + "Touch refolds evicted repository. The reader that saw Warming retries" + ); +} + +#[test] +fn a_collaborator_roll_doesnt_read_as_a_social_object() { + let world = World::new(); + let repo = repo_did("squid"); + world.seed_collaborator(&repo, "olaren"); + let (issue, record) = opening("3lubrptx57d22", "nel", 1, None, "kelp", 10); + let object = world.open_social(&repo, &issue, record, 10); + + let index = world.index(); + index.refresh_social(&repo).unwrap(); + assert_eq!( + index.social_of_kind(&repo, ¬e_collection()), + Resolved::Ready(vec![object]), + "roll shares repository with social objects, and isn't one of said social objects" + ); +} + +#[test] +fn two_creates_racing_on_one_repo_both_take_the_same_number() { + let (world, repo, _first_object, index) = folded_kelp(); + + let read = std::sync::Barrier::new(2); + let write = std::sync::Barrier::new(2); + let numbers: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = ["3lubrptx57d33", "3lubrptx57d44"] + .into_iter() + .map(|tid| { + let (index, world, repo, read, write) = (&index, &world, &repo, &read, &write); + scope.spawn(move || { + read.wait(); + let Resolved::Ready(Some(number)) = + index.next_subject_number(repo, ¬e_collection()) + else { + panic!("Projection is folded before either create reads it"); + }; + write.wait(); + let (change, record) = opening(tid, "nel", number.get(), None, "raced", 20); + let object = world.open_social(repo, &change, record, 20); + index + .refresh_social_object(repo, ¬e_type_name(), object) + .unwrap(); + number + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("Create thread runs to its answer")) + .collect() + }); + + assert_eq!( + numbers, + vec![ + SubjectNumber::new(2).unwrap(), + SubjectNumber::new(2).unwrap() + ], + "both creates read projection before either refreshed it: label #2 went" + ); + assert_eq!( + index.next_subject_number(&repo, ¬e_collection()), + Resolved::Ready(SubjectNumber::new(3)), + "Projection that took both refreshes counts past the shared label once" + ); + index.refresh_social(&repo).unwrap(); + assert_eq!( + index + .social_of_kind(&repo, ¬e_collection()) + .map(|objects| objects.len()), + Resolved::Ready(3), + "both raced objects exist, each with its own address, and only label collides" + ); +} + +#[test] +fn two_objects_claiming_one_address_leave_one_reachable() { + let (world, repo, first_object, index) = folded_kelp(); + let (second, second_record) = opening("3lubrptx57d22", "olaren", 2, None, "squatting", 20); + let second_object = world.open_social(&repo, &second, second_record, 20); + index.refresh_social(&repo).unwrap(); + + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Ready(Some(first_object)), + "Object that took its address first keeps said address, whatever order git lists refs in" + ); + assert_eq!( + index.social_of_kind(&repo, ¬e_collection()), + Resolved::Ready(vec![first_object]), + "lookup by address, and one by kind, agree, loser doesn't answer listings" + ); + assert_eq!( + index.social_author_of(&repo, second_object), + Resolved::Ready(Some(acc("olaren"))), + "Loser never stopped existing. The projection can say whose it is" + ); + assert_eq!( + index.social_address_of(&repo, second_object), + Resolved::Ready(None), + "but it doesn't answer its address, which keeps address_of and object_at one-to-one" + ); + assert_eq!( + index.social_address_of(&repo, first_object), + Resolved::Ready(Some(note_address("3lubrptx57d22"))) + ); + + let cold = world.index(); + cold.refresh_social(&repo).unwrap(); + assert_projections_agree( + &index, + &cold, + &repo, + &[first_object, second_object], + &[note_address("3lubrptx57d22")], + ); +} + +#[test] +fn refreshing_one_object_leaves_every_other_answer_where_the_cold_fold_puts_it() { + let (world, repo, incumbent, index) = folded_kelp(); + let (sibling, sibling_record) = opening("3lubrptx57d33", "nel", 2, None, "shell", 20); + let untouched = world.open_social(&repo, &sibling, sibling_record, 20); + index.refresh_social(&repo).unwrap(); + + let before = index.social_of_kind(&repo, ¬e_collection()); + + let (late, late_record) = opening("3lubrptx57d22", "olaren", 3, None, "squatting", 30); + let loser = world.open_social(&repo, &late, late_record, 30); + index + .refresh_social_object(&repo, ¬e_type_name(), loser) + .unwrap(); + + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d22")), + Resolved::Ready(Some(incumbent)), + "folding latecomer alone doesn't hand it incumbent's address" + ); + assert_eq!( + index.social_of_kind(&repo, ¬e_collection()), + before, + "contested arrival leaves listing alone, and sibling's order where it was" + ); + assert_eq!( + index.social_object_at(&repo, ¬e_address("3lubrptx57d33")), + Resolved::Ready(Some(untouched)), + "and object nobody contested answers for itself" + ); + + let cold = world.index(); + cold.refresh_social(&repo).unwrap(); + assert_projections_agree( + &index, + &cold, + &repo, + &[incumbent, untouched, loser], + &[note_address("3lubrptx57d22"), note_address("3lubrptx57d33")], + ); +} + +#[test] +fn a_change_that_opens_no_object_still_costs_the_repository_its_bytes() { + let (world, repo, object, index) = folded_kelp(); + let Resolved::Ready(opened) = index.social_structure(&repo) else { + panic!("Projection is folded"); + }; + + world.open_social_with( + &repo, + &erasing("nel", note_record("kelp").1, 20), + Record::Tombstone, + 20, + ); + index.refresh_social(&repo).unwrap(); + let Resolved::Ready(both) = index.social_structure(&repo) else { + panic!("Projection is folded"); + }; + assert!( + both > opened, + "History that never opens its object still has bytes, and the budget \ + that skipped them wouldn't bound anything" + ); + assert_eq!( + index.social_of_kind(&repo, ¬e_collection()), + Resolved::Ready(vec![object]), + "and it doesn't read as object at all" + ); +} diff --git a/knot2/crates/knot-index/tests/properties.rs b/knot2/crates/knot-index/tests/properties.rs index b772fb32d..5ff577574 100644 --- a/knot2/crates/knot-index/tests/properties.rs +++ b/knot2/crates/knot-index/tests/properties.rs @@ -4,7 +4,7 @@ use knot_cobs::{ Registration, RegistryChange, Removal, Rename, RepoRef, RepoRegistryCob, }; use knot_git::Repo; -use knot_index::{Index, Resolved, Standing}; +use knot_index::{EntryState, Index, Resolved}; use knot_types::{AccountDid, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; use proptest::prelude::*; @@ -75,6 +75,7 @@ fn registry_change(op: u8, who: u8, rkey: u8, repo: u8, t: i64) -> RegistryChang name: RepoName::new(format!("r{rkey}")).unwrap(), repo: repo_did(repo), created_at: UnixSeconds::new(t), + policy: knot_types::ContributionPolicy::default(), }), 1 => RegistryChange::Rename(Rename { owner: owner(who), @@ -121,16 +122,16 @@ proptest! { full.rebuild().unwrap(); let canonical = store.get::(object).unwrap(); - let roster = canonical.state(); - let expected: Vec>> = (0u8..4) - .map(|subject| Resolved::Ready(roster.standing(&acc(subject)))) + let roll = canonical.state(); + let expected: Vec>> = (0u8..4) + .map(|subject| Resolved::Ready(roll.state(&acc(subject)))) .collect(); prop_assert_eq!( - (0u8..4).map(|s| incremental.member_standing(&acc(s))).collect::>(), + (0u8..4).map(|s| incremental.member_state(&acc(s))).collect::>(), expected.clone() ); prop_assert_eq!( - (0u8..4).map(|s| full.member_standing(&acc(s))).collect::>(), + (0u8..4).map(|s| full.member_state(&acc(s))).collect::>(), expected ); } @@ -167,16 +168,16 @@ proptest! { full.refresh_collaborators(&repo).unwrap(); let canonical = store.get::(object).unwrap(); - let roster = canonical.state(); - let expected: Vec>> = (0u8..4) - .map(|subject| Resolved::Ready(roster.standing(&acc(subject)))) + let roll = canonical.state(); + let expected: Vec>> = (0u8..4) + .map(|subject| Resolved::Ready(roll.state(&acc(subject)))) .collect(); prop_assert_eq!( - (0u8..4).map(|s| incremental.collaborator_standing(&repo, &acc(s))).collect::>(), + (0u8..4).map(|s| incremental.collaborator_state(&repo, &acc(s))).collect::>(), expected.clone() ); prop_assert_eq!( - (0u8..4).map(|s| full.collaborator_standing(&repo, &acc(s))).collect::>(), + (0u8..4).map(|s| full.collaborator_state(&repo, &acc(s))).collect::>(), expected ); }