From 1679f3fdd9ea3c5653b04e2f47e18d87a82ef59c Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 2 Oct 2026 10:32:30 +0300 Subject: [PATCH] knot-index,knot-xrpc: ticket projections and interop Lewis: May this revision serve well! --- knot2/crates/knot-index/tests/common/mod.rs | 237 ++++--- knot2/crates/knot-index/tests/projections.rs | 654 ++++++------------ .../crates/knot-xrpc/tests/atproto_interop.rs | 79 +-- .../crates/knot-xrpc/tests/blob_properties.rs | 97 +-- knot2/crates/knot-xrpc/tests/blobs.rs | 71 +- knot2/crates/knot-xrpc/tests/common/mod.rs | 83 ++- knot2/crates/knot-xrpc/tests/reads.rs | 4 +- 7 files changed, 552 insertions(+), 673 deletions(-) diff --git a/knot2/crates/knot-index/tests/common/mod.rs b/knot2/crates/knot-index/tests/common/mod.rs index 79e0f1242..1669b5877 100644 --- a/knot2/crates/knot-index/tests/common/mod.rs +++ b/knot2/crates/knot-index/tests/common/mod.rs @@ -7,17 +7,20 @@ use knot_cob::{ KnotAuthored, Materialized, Record, WriteContext, }; use knot_cobs::{ - CollaboratorsChange, Creation, Editing, Erasure, Grant, IssueChange, IssueCob, - IssueStateChange, MembersChange, Registration, RegistryChange, Removal, Transition, + CollaboratorsChange, Creation, Editing, Erasure, Grant, MembersChange, Registration, + RegistryChange, Removal, TicketChange, TicketCob, }; use knot_git::{Layout, Repo}; use knot_index::{Index, KeyBudget}; -use knot_record::issue::IssueState; +use knot_record::comment::CommentRef; +use knot_record::pull::PatchRecord; +use knot_record::reaction::ReactionRecord; +use knot_record::ticket::TicketState; use knot_runtime::{K256Signer, SeededEntropy}; use knot_types::{ - AccountDid, Blocked, ContributionPermission, ContributionPolicy, KnotId, OwnerDid, - RecordAddress, RecordBody, RecordCid, RecordCollection, RecordRkey, RepoDid, RepoName, - RepoRkey, RepoRole, SubjectNumber, TypeName, UnixSeconds, dag_cbor_cid, + AccountDid, Blocked, ContributionPermission, ContributionPolicy, KnotId, LabelKey, Oid, + OwnerDid, RecordAddress, RecordBody, RecordCid, RecordCollection, RecordRkey, RepoDid, + RepoName, RepoRkey, RepoRole, SubjectNumber, TypeName, UnixSeconds, dag_cbor_cid, }; use serde::Serialize; use serde::de::DeserializeOwned; @@ -51,6 +54,12 @@ fn num(number: u32) -> SubjectNumber { SubjectNumber::new(number).expect("Subject is numbered from one") } +pub fn label_set(keys: &[&str]) -> Vec { + keys.iter() + .map(|key| LabelKey::new(*key).unwrap()) + .collect() +} + pub fn meta_home() -> CobHome { CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap()) } @@ -74,7 +83,7 @@ pub fn registration(owner_id: &str, key: &str, repo: &RepoDid, seconds: i64) -> } } -pub fn named_registration( +pub fn registration_with_name( owner_id: &str, key: &str, display: &str, @@ -306,57 +315,55 @@ impl World { .unwrap(); } - pub fn open_issue( + pub fn open_ticket( &self, repo: &RepoDid, - change: &IssueChange, + change: &TicketChange, record: Record, seconds: i64, ) -> CobId { - self.open_cob::(repo, change, record, seconds) + self.open_cob::(repo, change, record, seconds) } - pub fn append_issue( + pub fn append_ticket( &self, repo: &RepoDid, object: CobId, - change: &IssueChange, + change: &TicketChange, record: Record, seconds: i64, ) { - self.append_cob::(repo, object, change, record, seconds); + self.append_cob::(repo, object, change, record, seconds); } - pub fn issue( + #[allow(clippy::too_many_arguments)] + pub fn edit_ticket( &self, repo: &RepoDid, - key: &str, + object: CobId, author: &AccountDid, - number: u32, + from: RecordCid, text: &str, + tangled_labels: Option>, seconds: i64, - ) -> CobId { - self.opened( + ) { + let (record, _) = note_record(text); + self.append_ticket( repo, - key, - author, - number, - None, - knot_cobs::Issue, - text, + object, + &ticket_editing(author, from, text, tangled_labels, seconds), + Record::version(record), seconds, - ) + ); } - #[allow(clippy::too_many_arguments)] - pub fn transition( + pub fn ticket( &self, repo: &RepoDid, key: &str, author: &AccountDid, number: u32, - parent: &RecordAddress, - state: IssueState, + text: &str, seconds: i64, ) -> CobId { self.opened( @@ -364,76 +371,39 @@ impl World { key, author, number, - Some(parent), - Transition { state }, - key, + None, + knot_cobs::Ticket { + state: TicketState::Open, + tangled_labels: None, + }, + text, seconds, ) } #[allow(clippy::too_many_arguments)] - pub fn opened_as( + pub fn labeled_ticket( &self, repo: &RepoDid, key: &str, author: &AccountDid, number: u32, - parent: Option<&RecordAddress>, - body: B, - prose: &str, - seconds: i64, - role: RepoRole, - ) -> CobId { - let (change, record) = - contribution_of(key, author, number, parent, body, prose, at(seconds)); - let git = self - .layout - .open(repo) - .unwrap_or_else(|_| self.layout.create(repo).unwrap()); - let cx = WriteContext::new( - author, - repo, - ContributionPermission::new(Blocked::No, role, ContributionPolicy::Anyone), - ); - CobStore::new(&git) - .create_approved::>( - &CobHome::from(repo), - &Approved::signed_by( - &cx, - Materialized { - change: change.clone(), - record: Record::version(record), - }, - ) - .unwrap(), - &self.signer, - at(seconds), - ) - .unwrap() - .object - } - - #[allow(clippy::too_many_arguments)] - pub fn label_op( - &self, - repo: &RepoDid, - key: &str, - author: &AccountDid, - number: u32, - parent: &RecordAddress, - body: knot_cobs::LabelOp, + labels: Vec, + text: &str, seconds: i64, ) -> CobId { - self.opened_as( + self.opened( repo, key, author, number, - Some(parent), - body, - key, + None, + knot_cobs::Ticket { + state: TicketState::Open, + tangled_labels: Some(labels), + }, + text, seconds, - RepoRole::Collaborator, ) } @@ -491,7 +461,6 @@ impl World { knot_cobs::Comment { subject: RecordCid::new(dag_cbor_cid(b"subject")).unwrap(), reply: None, - round: None, }, text, seconds, @@ -509,16 +478,70 @@ impl World { kind: knot_cobs::ReactionKind, seconds: i64, ) -> CobId { - self.opened( - repo, - key, + let subject = CommentRef::new( + parent.clone(), + RecordCid::new(dag_cbor_cid(b"subject")).unwrap(), + ); + let bytes = ReactionRecord::new( + repo.clone(), + subject.clone(), + kind, + at(seconds), + author.clone(), + ) + .unwrap() + .body() + .unwrap(); + let change = contribution( + reaction_address(&tid(key)), author, number, - Some(parent), - knot_cobs::Reaction { kind }, - key, - seconds, + Some(parent.clone()), + bytes.cid(), + knot_cobs::Reaction { + subject: subject.cid(), + kind, + }, + at(seconds), + ); + self.open_cob::(repo, &change, Record::version(bytes), seconds) + } + + #[allow(clippy::too_many_arguments)] + pub fn patch( + &self, + repo: &RepoDid, + key: &str, + author: &AccountDid, + number: u32, + parent: &RecordAddress, + seconds: i64, + ) -> CobId { + let knot_types::RecordRkey::Minted(ticket) = parent.rkey() else { + panic!("a patch's parent is a ticket, keyed by a TID"); + }; + let record = PatchRecord::new( + ticket.clone(), + knot_git::CommitRange { + base: Oid::from_hex(&"1".repeat(40)).unwrap(), + head: Oid::from_hex(&"2".repeat(40)).unwrap(), + }, + at(seconds), + author.clone(), ) + .unwrap() + .body() + .unwrap(); + let change = contribution( + patch_address(&tid(key)), + author, + number, + Some(parent.clone()), + record.cid(), + knot_cobs::Patch, + at(seconds), + ); + self.open_cob::(repo, &change, Record::version(record), seconds) } } @@ -563,32 +586,30 @@ macro_rules! addressed { )+}; } -pub fn issue_at(key: &str) -> RecordAddress { - issue_address(&tid(key)) -} - -pub fn state_at(key: &str) -> RecordAddress { - state_address(&tid(key)) +pub fn ticket_at(key: &str) -> RecordAddress { + ticket_address(&tid(key)) } pub fn comment_at(key: &str) -> RecordAddress { comment_address(&tid(key)) } +pub fn patch_at(key: &str) -> RecordAddress { + patch_address(&tid(key)) +} + addressed! { note_collection of NoteCob::collection(), note_address - issue_collection of knot_record::issue::issue_collection(), issue_address, - issue_type_name of IssueChange - state_collection of knot_record::issue::issue_state_collection(), state_address, - state_type_name of IssueStateChange + ticket_collection of knot_record::ticket::ticket_collection(), ticket_address, + ticket_type_name of TicketChange comment_collection of knot_record::comment::comment_collection(), comment_address, comment_type_name of knot_cobs::CommentChange reaction_collection of knot_record::reaction::reaction_collection(), reaction_address, reaction_type_name of knot_cobs::ReactionChange label_def_collection of knot_record::label::label_def_collection(), label_def_address - label_op_collection of knot_record::label::label_op_collection(), label_op_address, - label_op_type_name of knot_cobs::LabelOpChange + patch_collection of knot_record::pull::patch_collection(), patch_address, + patch_type_name of knot_cobs::PatchChange } pub fn note_record(text: &str) -> (RecordBody, RecordCid) { @@ -663,19 +684,23 @@ pub fn contribution_of( ) } -pub fn issue_editing( +pub fn ticket_editing( author: &AccountDid, from: RecordCid, text: &str, + tangled_labels: Option>, seconds: i64, -) -> IssueChange { - IssueChange::Edit { +) -> TicketChange { + TicketChange::Edit { editing: Editing { author: author.clone(), from, produces: note_record(text).1, edited_at: at(seconds), }, - body: knot_cobs::Issue, + body: knot_cobs::Ticket { + state: TicketState::Open, + tangled_labels, + }, } } diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index 15b08ca7b..ed097de3e 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -9,17 +9,16 @@ use knot_index::{ KeyReprieved, KeyTtl, OfferedKey, Resolved, SocialEntryState, SocialResidencyBytes, SweepInterval, }; -use knot_record::issue::IssueState; use knot_types::{ClonePath, RecordAddress, RecordCid, RefName, RepoDid, RepoName, SubjectNumber}; use serde::{Deserialize, Serialize}; mod common; use common::{ - NoteBody, NoteCob, World, acc, at, comment_at, comment_collection, contribution_of, erased, - grant, issue_at, issue_collection, issue_editing, issue_type_name, label_def_collection, - label_op_collection, label_op_type_name, meta_home, named_registration, note_address, - note_record, own, reaction_collection, registration, repo_did, rkey, state_at, - state_collection, state_type_name, tid, + NoteBody, NoteCob, World, acc, at, comment_at, comment_collection, comment_type_name, + contribution_of, erased, grant, label_def_collection, label_set, meta_home, note_address, + note_record, own, patch_at, patch_collection, patch_type_name, reaction_collection, + registration, registration_with_name, repo_did, rkey, ticket_at, ticket_collection, + ticket_type_name, tid, }; use knot_cobs::ReactionKind; @@ -1160,7 +1159,7 @@ fn a_clone_path_resolves_by_name_when_the_record_key_is_a_tid() { store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mizfnpxii522", "substratum.cloud", @@ -1207,7 +1206,9 @@ fn a_record_key_outranks_another_repos_name() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration("nel", "limpet", "mussel", &by_name, 1)), + &RegistryChange::Register(registration_with_name( + "nel", "limpet", "mussel", &by_name, 1, + )), &world.signer, at(1), ) @@ -1217,7 +1218,9 @@ fn a_record_key_outranks_another_repos_name() { .update( &meta_home(), object, - &RegistryChange::Register(named_registration("nel", "mussel", "scallop", &by_rkey, 2)), + &RegistryChange::Register(registration_with_name( + "nel", "mussel", "scallop", &by_rkey, 2, + )), &world.signer, at(2), ) @@ -1248,7 +1251,7 @@ fn a_name_two_repos_share_resolves_to_the_older_registration() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3lubrptx57d22", "kelp", @@ -1264,7 +1267,7 @@ fn a_name_two_repos_share_resolves_to_the_older_registration() { .update( &meta_home(), object, - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mqydma3re27z", "kelp", @@ -1308,7 +1311,7 @@ fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3lubrptx57d22", "kelp", @@ -1324,7 +1327,7 @@ fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { .update( &meta_home(), object, - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mqydma3re27z", "uni", @@ -1383,7 +1386,7 @@ fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3lubrptx57d22", "kelp", @@ -1399,7 +1402,7 @@ fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() { .update( &meta_home(), object, - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mqydma3re27z", "kelp", @@ -1450,7 +1453,7 @@ fn a_rename_moves_name_resolution_off_the_old_name() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mizfnpxii522", "anemone", @@ -1512,7 +1515,13 @@ fn a_rename_that_changes_only_the_record_key_keeps_the_name_resolving() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration("nel", "3lubrptx57d22", "kelp", &repo, 1)), + &RegistryChange::Register(registration_with_name( + "nel", + "3lubrptx57d22", + "kelp", + &repo, + 1, + )), &world.signer, at(1), ) @@ -1565,7 +1574,7 @@ fn seeding_and_replaying_agree_on_name_resolution() { let object = store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3lubrptx57d22", "kelp", @@ -1585,7 +1594,7 @@ fn seeding_and_replaying_agree_on_name_resolution() { .update( &meta_home(), object, - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "3mqydma3re27z", "uni", @@ -1622,7 +1631,7 @@ fn a_name_differing_from_its_record_key_only_by_case_resolves() { store .create( &meta_home(), - &RegistryChange::Register(named_registration( + &RegistryChange::Register(registration_with_name( "nel", "runic_lang", "Runic_lang", @@ -1650,15 +1659,15 @@ fn a_name_differing_from_its_record_key_only_by_case_resolves() { ); } -fn kelp_issue() -> (World, RepoDid, CobId) { +fn kelp_ticket() -> (World, RepoDid, CobId) { let world = World::new(); let repo = repo_did("squid"); - let object = world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); + let object = world.ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); (world, repo, object) } fn folded_kelp() -> (World, RepoDid, CobId, knot_index::Index) { - let (world, repo, object) = kelp_issue(); + let (world, repo, object) = kelp_ticket(); let index = world.index(); index.refresh_social(&repo).unwrap(); (world, repo, object, index) @@ -1672,12 +1681,11 @@ fn assert_projections_agree( addresses: &[RecordAddress], ) { [ - issue_collection(), - state_collection(), + ticket_collection(), comment_collection(), reaction_collection(), label_def_collection(), - label_op_collection(), + patch_collection(), ] .into_iter() .for_each(|collection| { @@ -1725,10 +1733,10 @@ fn assert_projections_agree( "Incremental fold and cold fold disagree about the standing labels on {at}" ); [ - issue_collection(), - state_collection(), + ticket_collection(), comment_collection(), reaction_collection(), + patch_collection(), ] .into_iter() .for_each(|collection| { @@ -1744,15 +1752,15 @@ fn assert_projections_agree( #[test] fn social_projection_answers_by_address_by_kind_and_by_parent() { - let (world, repo, issue_object) = kelp_issue(); - let second_object = world.issue(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 20); - let state_object = world.transition( + let (world, repo, issue_object) = kelp_ticket(); + let second_object = world.ticket(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 20); + let comment_object = world.comment( &repo, "3lubrptx57d44", &acc("nel"), 1, - &issue_at("3lubrptx57d22"), - IssueState::Closed, + &ticket_at("3lubrptx57d22"), + "kelp reply", 30, ); @@ -1760,83 +1768,67 @@ fn social_projection_answers_by_address_by_kind_and_by_parent() { index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("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(issue_at("3lubrptx57d22"))), + Resolved::Ready(Some(ticket_at("3lubrptx57d22"))), "and object resolves back to its address" ); assert_eq!( - index.social_of_kind(&repo, &issue_collection()), + index.social_of_kind(&repo, &ticket_collection()), Resolved::Ready(vec![issue_object, second_object]), "Kind must read in write order: number first, then createdAt" ); assert_eq!( - index.social_of_kind(&repo, &state_collection()), - Resolved::Ready(vec![state_object]), - "under its own collection, never the issue's, a transition should read" + index.social_of_kind(&repo, &comment_collection()), + Resolved::Ready(vec![comment_object]), + "under its own collection, never the ticket's, a comment should read" ); assert_eq!( - index.social_children_of(&repo, &issue_at("3lubrptx57d22")), - Resolved::Ready(vec![state_object]), + index.social_children_of(&repo, &ticket_at("3lubrptx57d22")), + Resolved::Ready(vec![comment_object]), "child names its parent's address, and parent reads its children back" ); assert_eq!( - index.social_latest_child(&repo, &issue_at("3lubrptx57d22"), &state_collection()), - Resolved::Ready(Some(state_at("3lubrptx57d44"))), - "Latest transition is the one fold's order puts last" + index.social_latest_child(&repo, &ticket_at("3lubrptx57d22"), &comment_collection()), + Resolved::Ready(Some(comment_at("3lubrptx57d44"))), + "Latest comment is the one fold's order puts last" ); assert_eq!( - index.social_latest_child(&repo, &issue_at("3lubrptx57d22"), &issue_collection()), + index.social_latest_child(&repo, &ticket_at("3lubrptx57d22"), &ticket_collection()), Resolved::Ready(None), "asking under a kind without children under this parent shouldn't hand back the \ - transition either" + comment either" ); assert_eq!( - index.social_children_of(&repo, &issue_at("3lubrptx57d33")), + index.social_children_of(&repo, &ticket_at("3lubrptx57d33")), Resolved::Ready(Vec::new()), "Object nobody answered has no children" ); } #[test] -fn backdated_transition_still_ranks_latest_by_write_order() { - let (world, repo, _issue) = kelp_issue(); - let parent = issue_at("3lubrptx57d22"); - world.transition( - &repo, - "3lubrptx57d44", - &acc("nel"), - 1, - &parent, - IssueState::Closed, - 30, - ); - world.transition( - &repo, - "3lubrptx57d55", - &acc("nel"), - 2, - &parent, - IssueState::Open, - 5, - ); +fn backdated_comment_still_ranks_latest_by_write_order() { + let (world, repo, _issue) = kelp_ticket(); + let parent = ticket_at("3lubrptx57d22"); + world.comment(&repo, "3lubrptx57d44", &acc("nel"), 1, &parent, "first", 30); + world.comment(&repo, "3lubrptx57d55", &acc("nel"), 2, &parent, "second", 5); let index = world.index(); index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_latest_child(&repo, &parent, &state_collection()), - Resolved::Ready(Some(state_at("3lubrptx57d55"))), + index.social_latest_child(&repo, &parent, &comment_collection()), + Resolved::Ready(Some(comment_at("3lubrptx57d55"))), "the third write must be current even though its clock read earlier than the second's" ); } #[test] fn erased_subject_stops_answering_children_and_comment_keeps_serving() { - let (world, repo, issue_object) = kelp_issue(); - let parent = issue_at("3lubrptx57d22"); + let (world, repo, issue_object) = kelp_ticket(); + let parent = ticket_at("3lubrptx57d22"); let comment_object = world.comment( &repo, "3lubrptx57d33", @@ -1856,11 +1848,11 @@ fn erased_subject_stops_answering_children_and_comment_keeps_serving() { assert_eq!( index.social_latest_child(&repo, &parent, &comment_collection()), Resolved::Ready(Some(comment_at("3lubrptx57d33"))), - "the newest comment under the issue shoukd probably come back too" + "the newest comment under the issue should probably come back too" ); let before = state_of(&index, &repo, comment_object).expect("Index should contain the comment"); - world.erase::( + world.erase::( &repo, issue_object, &acc("nel"), @@ -1893,8 +1885,8 @@ fn erased_subject_stops_answering_children_and_comment_keeps_serving() { #[test] fn reaction_index_answers_live_reactions_only() { - let (world, repo, _issue) = kelp_issue(); - let parent = issue_at("3lubrptx57d22"); + let (world, repo, _issue) = kelp_ticket(); + let parent = ticket_at("3lubrptx57d22"); let nels_like = world.reaction( &repo, "3lubrptx57d44", @@ -1968,31 +1960,33 @@ fn reaction_index_answers_live_reactions_only() { #[test] fn untouched_repo_stays_warming_until_write_reaches_answer() { - let (world, repo, object) = kelp_issue(); + let (world, repo, object) = kelp_ticket(); let index = world.index(); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Warming, "unfolded repository fails closed, turning away write that reads it" ); assert_eq!( - index.next_subject_number(&repo, &issue_collection()), + index.next_subject_number(&repo, &ticket_collection()), Resolved::Warming ); index.ensure_social(&repo).unwrap(); assert_eq!( - index.next_subject_number(&repo, &issue_collection()), + index.next_subject_number(&repo, &ticket_collection()), Resolved::Ready(SubjectNumber::new(2)), "Next number is one past the highest the projection has" ); - world.append_issue( + world.edit_ticket( &repo, object, - &issue_editing(&acc("nel"), note_record("kelp").1, "kelp forest", 20), - Record::version(note_record("kelp forest").0), + &acc("nel"), + note_record("kelp").1, + "kelp forest", + None, 20, ); index.refresh_social(&repo).unwrap(); @@ -2002,13 +1996,7 @@ fn untouched_repo_stays_warming_until_write_reaches_answer() { "Projection follows version latest change produced" ); - world.append_issue( - &repo, - object, - &erased(&acc("nel"), note_record("kelp forest").1, at(30)), - Record::Tombstone, - 30, - ); + world.erase::(&repo, object, &acc("nel"), note_record("kelp forest").1, 30); index.refresh_social(&repo).unwrap(); assert_eq!( state_of(&index, &repo, object), @@ -2016,7 +2004,7 @@ fn untouched_repo_stays_warming_until_write_reaches_answer() { "Erased object doesn't have version" ); assert_eq!( - index.next_subject_number(&repo, &issue_collection()), + index.next_subject_number(&repo, &ticket_collection()), Resolved::Ready(SubjectNumber::new(2)), "Deletion leaves its number spent, as every forge does" ); @@ -2038,48 +2026,60 @@ fn version_of(index: &knot_index::Index, repo: &RepoDid, object: CobId) -> Optio #[test] fn git_rebuild_and_lone_rebuilds_agree_with_incremental_replay() { - let (world, repo, object) = kelp_issue(); - let untouched_object = world.issue(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 15); - let parent = issue_at("3lubrptx57d22"); - let reply_object = world.transition( + let (world, repo, object) = kelp_ticket(); + let untouched_object = world.ticket(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 15); + let parent = ticket_at("3lubrptx57d22"); + let reply_object = world.comment( &repo, "3lubrptx57d44", &acc("olaren"), 1, &parent, - IssueState::Closed, + "urchin reply", 16, ); let replayed = world.index(); replayed.refresh_social(&repo).unwrap(); - world.append_issue( + world.edit_ticket( &repo, object, - &issue_editing(&acc("nel"), note_record("kelp").1, "kelp forest", 20), - Record::version(note_record("kelp forest").0), + &acc("nel"), + note_record("kelp").1, + "kelp forest", + None, 20, ); replayed - .refresh_social_object(&repo, &issue_type_name(), object) + .refresh_social_object(&repo, &ticket_type_name(), object) .unwrap(); - let reopened_object = world.transition( + let reopened_object = world.comment( &repo, "3lubrptx57d55", &acc("nel"), 2, &parent, - IssueState::Open, + "kelp reply", 21, ); replayed - .refresh_social_object(&repo, &state_type_name(), reopened_object) + .refresh_social_object(&repo, &comment_type_name(), reopened_object) .unwrap(); assert_eq!( - replayed.social_latest_child(&repo, &parent, &state_collection()), - Resolved::Ready(Some(state_at("3lubrptx57d55"))), - "a transition replayed on its own must still become the issue's current state" + replayed.social_latest_child(&repo, &parent, &comment_collection()), + Resolved::Ready(Some(comment_at("3lubrptx57d55"))), + "a comment replayed on its own must still become the ticket's latest child" + ); + + let patch_object = world.patch(&repo, "3lubrptx57d66", &acc("nel"), 1, &parent, 22); + replayed + .refresh_social_object(&repo, &patch_type_name(), patch_object) + .unwrap(); + assert_eq!( + replayed.social_latest_child(&repo, &parent, &patch_collection()), + Resolved::Ready(Some(patch_at("3lubrptx57d66"))), + "a patch replayed on its own must still become the ticket's latest patch" ); let cold = world.index(); @@ -2088,17 +2088,24 @@ fn git_rebuild_and_lone_rebuilds_agree_with_incremental_replay() { &replayed, &cold, &repo, - &[object, untouched_object, reply_object, reopened_object], &[ - issue_at("3lubrptx57d22"), - issue_at("3lubrptx57d33"), - state_at("3lubrptx57d44"), - state_at("3lubrptx57d55"), + object, + untouched_object, + reply_object, + reopened_object, + patch_object, + ], + &[ + ticket_at("3lubrptx57d22"), + ticket_at("3lubrptx57d33"), + comment_at("3lubrptx57d44"), + comment_at("3lubrptx57d55"), + patch_at("3lubrptx57d66"), ], ); assert_eq!( cold.social_children_of(&repo, &parent), - Resolved::Ready(vec![reply_object, reopened_object]), + Resolved::Ready(vec![reply_object, patch_object, reopened_object]), "in the child index, each reply oughtta list under the object it answered" ); assert_eq!( @@ -2111,242 +2118,40 @@ fn git_rebuild_and_lone_rebuilds_agree_with_incremental_replay() { .into_iter() .filter(|entry| entry.state.serving().is_some()) .count()), - Resolved::Ready(4) + Resolved::Ready(5) ); } -fn label_operand(key: &str, value: &str) -> knot_record::label::LabelOperand { - knot_record::label::LabelOperand::new( - knot_record::label::DefRef::parse( - &knot_types::AtUri::new_owned(format!( - "at://did:plc:limpet/sh.tangled.label.definition/{key}" - )) - .unwrap(), - ) - .unwrap(), - knot_record::label::OperandValue::new(value).unwrap(), - ) -} - -fn label_body( - subject: &knot_types::RecordAddress, - adds: Vec, - deletes: Vec, - defs: &[(&str, bool)], -) -> knot_cobs::LabelOp { - let declared: std::collections::BTreeMap = - defs.iter() - .map(|(slug, multiple)| { - ( - label_operand(slug, "null").key().clone(), - knot_record::label::Def::new( - knot_record::label::LabelName::new(*slug).unwrap(), - knot_record::label::LabelValue::Text( - knot_record::label::TextConstraint::free( - knot_record::label::LabelFormat::Any, - ), - ), - vec![knot_record::issue::issue_collection()], - None, - *multiple, - ) - .unwrap(), - ) - }) - .collect(); - let record = knot_record::label::LabelOpRecord::new( - knot_types::RepoDid::new("did:plc:squid").unwrap(), - subject.clone(), - adds, - deletes, - knot_types::UnixSeconds::new(20), - acc("nel"), - ) - .unwrap(); - knot_cobs::LabelOp::applications(&record, &declared).unwrap() -} - -fn spelled( - labels: std::collections::BTreeMap< - knot_record::label::DefRef, - Vec, - >, -) -> std::collections::BTreeMap> { - labels - .into_iter() - .map(|(def, values)| { - ( - def.to_string(), - values - .into_iter() - .map(|value| value.as_str().to_owned()) - .collect(), - ) - }) - .collect() -} - #[test] -fn standing_labels_match_a_consumer_replaying_the_ops() { +fn ticket_labels_serve_the_tangled_set() { let world = World::new(); let repo = repo_did("squid"); - world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); - - let subject = issue_at("3lubrptx57d22"); - let single = "size"; - let multiple = "reviewer"; - let declared = [(single, false), (multiple, true)]; - let ops = [ - ( - vec![ - label_operand(multiple, "conch"), - label_operand(multiple, "limpet"), - ], - Vec::new(), - ), - ( - vec![ - label_operand(single, "small"), - label_operand(single, "large"), - ], - Vec::new(), - ), - ( - vec![label_operand(multiple, "conch")], - vec![label_operand(multiple, "conch")], - ), - (Vec::new(), vec![label_operand(single, "small")]), - ]; - [ - ("3lubrptx57d33", &ops[0]), - ("3lubrptx57d44", &ops[1]), - ("3lubrptx57d55", &ops[2]), - ("3lubrptx57d66", &ops[3]), - ] - .into_iter() - .enumerate() - .for_each(|(at, (key, (adds, deletes)))| { - world.label_op( - &repo, - key, - &acc("nel"), - (at + 2) as u32, - &subject, - label_body(&subject, adds.clone(), deletes.clone(), &declared), - 20 + at as i64, - ); - }); + let labels = label_set(&["wontfix", "reviewer"]); + world.labeled_ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, labels, "kelp", 10); + let subject = ticket_at("3lubrptx57d22"); let index = world.index(); index.refresh_social(&repo).unwrap(); - - fn consumer( - ops: &[( - Vec, - Vec, - )], - multiple_of: impl Fn(&str) -> bool, - ) -> std::collections::BTreeMap> { - let mut standing: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - ops.iter().for_each(|(adds, deletes)| { - adds.iter() - .map(|operand| (operand, knot_cobs::LabelVerb::Add)) - .chain( - deletes - .iter() - .map(|operand| (operand, knot_cobs::LabelVerb::Delete)), - ) - .for_each(|(operand, verb)| { - let key = operand.key().to_string(); - let value = operand.value().as_str().to_owned(); - let set = standing.entry(key.clone()).or_default(); - let multiple = multiple_of(&key); - if verb == knot_cobs::LabelVerb::Delete { - if multiple { - set.retain(|standing| *standing != value); - } else if set.first().is_some_and(|standing| *standing == value) { - set.clear(); - } - } else if multiple { - if !set.contains(&value) { - set.push(value); - } - } else { - set.clear(); - set.push(value); - } - }); - }); - standing.retain(|_, values| !values.is_empty()); - standing - } - assert_eq!( - index.social_labels_of(&repo, &subject).map(spelled), - Resolved::Ready(consumer(&ops, |key| key.ends_with("/reviewer"))), - "the projection's standing set is what a listOps consumer computes from the same records" - ); - let expected = Resolved::Ready(std::collections::BTreeMap::from([ - ( - "at://did:plc:limpet/sh.tangled.label.definition/reviewer".to_owned(), - vec!["limpet".to_owned()], - ), - ( - "at://did:plc:limpet/sh.tangled.label.definition/size".to_owned(), - vec!["large".to_owned()], - ), - ])); - assert_eq!( - index.social_labels_of(&repo, &subject).map(spelled), - expected, - "reviewer keeps limpet after conch's add-then-delete in one op, size stands at large \ - after the last add replaced small, and small's delete is a no-op" + index.social_labels_of(&repo, &subject), + Resolved::Ready(label_set(&["wontfix", "reviewer"])), + "the ticket's tangledLabels is the live set" ); } #[test] -fn an_erased_op_removes_the_label_from_the_standing_set() { +fn an_unlabeled_ticket_serves_no_labels() { let world = World::new(); let repo = repo_did("squid"); - world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); - - let subject = issue_at("3lubrptx57d22"); - let op = world.label_op( - &repo, - "3lubrptx57d33", - &acc("nel"), - 2, - &subject, - label_body( - &subject, - vec![label_operand("wontfix", "null")], - Vec::new(), - &[("wontfix", false)], - ), - 20, - ); + world.ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); + let subject = ticket_at("3lubrptx57d22"); let index = world.index(); index.refresh_social(&repo).unwrap(); - let (_, standing) = note_record("3lubrptx57d33"); - assert_eq!( - index.social_labels_of(&repo, &subject).map(spelled), - Resolved::Ready(std::collections::BTreeMap::from([( - "at://did:plc:limpet/sh.tangled.label.definition/wontfix".to_owned(), - vec!["null".to_owned()] - )])), - "before the erase, the label the op added is standing" - ); - - world.erase::(&repo, op, &acc("nel"), standing, 30); - index - .refresh_social_object(&repo, &label_op_type_name(), op) - .unwrap(); assert_eq!( index.social_labels_of(&repo, &subject), - Resolved::Ready(std::collections::BTreeMap::new()), - "an erased op takes the label out of the standing set" + Resolved::Ready(Vec::new()), + "a ticket without tangledLabels serves an empty set" ); } @@ -2354,50 +2159,46 @@ fn an_erased_op_removes_the_label_from_the_standing_set() { fn incremental_label_refresh_matches_a_cold_fold() { let world = World::new(); let repo = repo_did("squid"); - world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); - let subject = issue_at("3lubrptx57d22"); + let labels = label_set(&["wontfix", "reviewer"]); + let object = world.labeled_ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, labels, "kelp", 10); + let subject = ticket_at("3lubrptx57d22"); let warmed = world.index(); warmed.refresh_social(&repo).unwrap(); - let op = world.label_op( - &repo, - "3lubrptx57d33", - &acc("nel"), - 2, - &subject, - label_body( - &subject, - vec![ - label_operand("wontfix", "null"), - label_operand("reviewer", "conch"), - ], - Vec::new(), - &[("wontfix", false), ("reviewer", true)], - ), - 20, - ); - warmed - .refresh_social_object(&repo, &label_op_type_name(), op) - .unwrap(); let cold = world.index(); cold.refresh_social(&repo).unwrap(); assert_eq!( cold.social_labels_of(&repo, &subject), warmed.social_labels_of(&repo, &subject), - "a label op folded incrementally onto a warm projection serves the same standing set \ + "a ticket folded incrementally onto a warm projection serves the same tangled set \ as a cold fold from the git history" ); - let (_, standing) = note_record("3lubrptx57d33"); - world.erase::(&repo, op, &acc("nel"), standing, 30); + let labels = Some(label_set(&["reviewer"])); + world.edit_ticket( + &repo, + object, + &acc("nel"), + note_record("kelp").1, + "kelp forest", + labels, + 20, + ); warmed - .refresh_social_object(&repo, &label_op_type_name(), op) + .refresh_social_object(&repo, &ticket_type_name(), object) .unwrap(); - cold.refresh_social(&repo).unwrap(); + let refolded = world.index(); + refolded.refresh_social(&repo).unwrap(); + let edited = Resolved::Ready(label_set(&["reviewer"])); assert_eq!( - cold.social_labels_of(&repo, &subject), warmed.social_labels_of(&repo, &subject), - "nor do they disagree once the op is erased incrementally" + edited, + "an edit's tangledLabels replaces the opened set on a warm projection" + ); + assert_eq!( + refolded.social_labels_of(&repo, &subject), + edited, + "and a cold fold off the git history serves the edited set too" ); } @@ -2405,43 +2206,26 @@ fn incremental_label_refresh_matches_a_cold_fold() { fn deleted_subject_stops_serving_its_labels() { let world = World::new(); let repo = repo_did("squid"); - let issue = world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); - - let subject = issue_at("3lubrptx57d22"); - world.label_op( - &repo, - "3lubrptx57d33", - &acc("nel"), - 2, - &subject, - label_body( - &subject, - vec![label_operand("wontfix", "null")], - Vec::new(), - &[("wontfix", false)], - ), - 20, - ); + let labels = label_set(&["wontfix"]); + let issue = world.labeled_ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, labels, "kelp", 10); + let subject = ticket_at("3lubrptx57d22"); let index = world.index(); index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_labels_of(&repo, &subject).map(spelled), - Resolved::Ready(std::collections::BTreeMap::from([( - "at://did:plc:limpet/sh.tangled.label.definition/wontfix".to_owned(), - vec!["null".to_owned()] - )])), - "while the issue stands, so does the label" + index.social_labels_of(&repo, &subject), + Resolved::Ready(label_set(&["wontfix"])), + "while the ticket stands, so does the label" ); - let (_, standing) = note_record("kelp"); - world.erase::(&repo, issue, &acc("nel"), standing, 30); + let (_, live) = note_record("kelp"); + world.erase::(&repo, issue, &acc("nel"), live, 30); index - .refresh_social_object(&repo, &issue_type_name(), issue) + .refresh_social_object(&repo, &ticket_type_name(), issue) .unwrap(); assert_eq!( index.social_labels_of(&repo, &subject), - Resolved::Ready(std::collections::BTreeMap::new()), + Resolved::Ready(Vec::new()), "a deleted subject stops serving the label set along with the children" ); } @@ -2452,7 +2236,7 @@ enum BadIssue { Explode(u8), } impl ChangePayload for BadIssue { - const TYPE: &'static str = "sh.tangled.repo.issue"; + const TYPE: &'static str = "org.tangled.track.ticket"; } impl KnotAuthored for BadIssue {} @@ -2475,13 +2259,13 @@ fn served(index: &knot_index::Index, repo: &RepoDid) -> u64 { #[test] fn serving_bytes_answer_live_versions_and_erasure_frees_them() { - let (world, repo, object) = kelp_issue(); + let (world, repo, object) = kelp_ticket(); let index = world.index(); index.refresh_social(&repo).unwrap(); - let urchin = world.issue(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 15); + let urchin = world.ticket(&repo, "3lubrptx57d33", &acc("olaren"), 2, "urchin", 15); index - .refresh_social_object(&repo, &issue_type_name(), urchin) + .refresh_social_object(&repo, &ticket_type_name(), urchin) .unwrap(); let kelp = note_record("kelp").0.len() as u64; @@ -2493,15 +2277,17 @@ fn serving_bytes_answer_live_versions_and_erasure_frees_them() { "each object answers the bytes of the version it serves, and no past version counts" ); - world.append_issue( + world.edit_ticket( &repo, object, - &issue_editing(&acc("nel"), note_record("kelp").1, "kelp forest", 20), - Record::version(note_record("kelp forest").0), + &acc("nel"), + note_record("kelp").1, + "kelp forest", + None, 20, ); index - .refresh_social_object(&repo, &issue_type_name(), object) + .refresh_social_object(&repo, &ticket_type_name(), object) .unwrap(); assert_eq!( served(&index, &repo), @@ -2509,15 +2295,9 @@ fn serving_bytes_answer_live_versions_and_erasure_frees_them() { "an edit answers its new version, freed of the one it replaced" ); - world.append_issue( - &repo, - object, - &erased(&acc("nel"), note_record("kelp forest").1, at(30)), - Record::Tombstone, - 30, - ); + world.erase::(&repo, object, &acc("nel"), note_record("kelp forest").1, 30); index - .refresh_social_object(&repo, &issue_type_name(), object) + .refresh_social_object(&repo, &ticket_type_name(), object) .unwrap(); assert_eq!( served(&index, &repo), @@ -2537,9 +2317,9 @@ fn serving_bytes_answer_live_versions_and_erasure_frees_them() { fn contested_claims_stop_counting_toward_serving_bytes() { let world = World::new(); let repo = repo_did("squid"); - world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); + world.ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); - world.issue(&repo, "3lubrptx57d22", &acc("olaren"), 2, "urchin", 20); + world.ticket(&repo, "3lubrptx57d22", &acc("olaren"), 2, "urchin", 20); let index = world.index(); index.refresh_social(&repo).unwrap(); assert_eq!( @@ -2553,7 +2333,7 @@ fn contested_claims_stop_counting_toward_serving_bytes() { fn unreadable_social_change_answers_warming_until_repo_or_object_refresh() { let (world, repo, object, index) = folded_kelp(); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Ready(Some(object)) ); @@ -2563,20 +2343,20 @@ fn unreadable_social_change_answers_warming_until_repo_or_object_refresh() { Err(IndexError::Decode { .. }) )); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("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, &issue_collection()), + index.next_subject_number(&repo, &ticket_collection()), Resolved::Warming ); assert!(matches!( - index.refresh_social_object(&repo, &issue_type_name(), object), + index.refresh_social_object(&repo, &ticket_type_name(), object), Err(IndexError::Decode { .. }) )); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Warming, "an object refresh that can't read git shouldn't leave a stale answer behind for the next write" ); @@ -2587,7 +2367,7 @@ fn unprojected_cob_skips_without_failing_repository() { let world = World::new(); let repo = repo_did("squid"); world.seed_collaborator(&repo, "olaren"); - let object = world.issue(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); + let object = world.ticket(&repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); let (note, note_body) = contribution_of::( "3lubrptx57d33", &acc("nel"), @@ -2605,7 +2385,7 @@ fn unprojected_cob_skips_without_failing_repository() { let index = world.index(); index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_of_kind(&repo, &issue_collection()), + index.social_of_kind(&repo, &ticket_collection()), Resolved::Ready(vec![object]), "without becoming social objects, a roll and a COB of a type this crate doesn't project \ ought to share the repository, and an unreadable one of them shouldn't take the \ @@ -2625,7 +2405,7 @@ fn resident(multiple: u64) -> (World, Vec, knot_index::Index) { .map(repo_did) .collect(); repos.iter().for_each(|repo| { - world.issue(repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); + world.ticket(repo, "3lubrptx57d22", &acc("nel"), 1, "kelp", 10); }); let measured = world.index(); measured.ensure_social(&repos[0]).unwrap(); @@ -2645,18 +2425,18 @@ fn eviction_past_residency_forgets_coldest_and_skips_guarded_repo() { .iter() .for_each(|repo| index.ensure_social(repo).unwrap()); assert_eq!( - index.social_of_kind(&repos[0], &issue_collection()), + index.social_of_kind(&repos[0], &ticket_collection()), Resolved::Warming, "repository rebuilt longest ago ought to make room for a newcomer" ); assert!(matches!( - index.social_of_kind(&repos[2], &issue_collection()), + index.social_of_kind(&repos[2], &ticket_collection()), Resolved::Ready(_) )); index.ensure_social(&repos[0]).unwrap(); assert!( matches!( - index.social_of_kind(&repos[0], &issue_collection()), + index.social_of_kind(&repos[0], &ticket_collection()), Resolved::Ready(_) ), "touch must rebuild an evicted repository" @@ -2667,14 +2447,14 @@ fn eviction_past_residency_forgets_coldest_and_skips_guarded_repo() { index.ensure_social(&repos[1]).unwrap(); assert!( matches!( - index.social_of_kind(&repos[0], &issue_collection()), + index.social_of_kind(&repos[0], &ticket_collection()), Resolved::Ready(_) ), "a write keeping its repository resident oughtn't lose it to somebody else's publish" ); assert!( matches!( - index.social_of_kind(&repos[1], &issue_collection()), + index.social_of_kind(&repos[1], &ticket_collection()), Resolved::Ready(_) ), "the publish that couldn't make room should still serve what it rebuilt" @@ -2683,11 +2463,11 @@ fn eviction_past_residency_forgets_coldest_and_skips_guarded_repo() { drop(guard); index.ensure_social(&repos[2]).unwrap(); assert_eq!( - index.social_of_kind(&repos[0], &issue_collection()), + index.social_of_kind(&repos[0], &ticket_collection()), Resolved::Warming ); assert_eq!( - index.social_of_kind(&repos[1], &issue_collection()), + index.social_of_kind(&repos[1], &ticket_collection()), Resolved::Warming, "once the guard drops, eviction should run until the resident bytes fit" ); @@ -2707,14 +2487,14 @@ fn two_creates_racing_on_one_repo_both_take_the_same_number() { scope.spawn(move || { read.wait(); let Resolved::Ready(Some(number)) = - index.next_subject_number(repo, &issue_collection()) + index.next_subject_number(repo, &ticket_collection()) else { panic!("Projection is folded before either create reads it"); }; write.wait(); - let object = world.issue(repo, key, &acc("nel"), number.get(), "raced", 20); + let object = world.ticket(repo, key, &acc("nel"), number.get(), "raced", 20); index - .refresh_social_object(repo, &issue_type_name(), object) + .refresh_social_object(repo, &ticket_type_name(), object) .unwrap(); number }) @@ -2735,14 +2515,14 @@ fn two_creates_racing_on_one_repo_both_take_the_same_number() { "both creates read projection before either refreshed it: label #2 went" ); assert_eq!( - index.next_subject_number(&repo, &issue_collection()), + index.next_subject_number(&repo, &ticket_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, &issue_collection()) + .social_of_kind(&repo, &ticket_collection()) .map(|objects| objects.len()), Resolved::Ready(3), "both raced objects exist, each with its own address, and only label collides" @@ -2752,16 +2532,16 @@ fn two_creates_racing_on_one_repo_both_take_the_same_number() { #[test] fn two_objects_claiming_one_address_leave_one_reachable() { let (world, repo, first_object, index) = folded_kelp(); - let second_object = world.issue(&repo, "3lubrptx57d22", &acc("olaren"), 2, "squatting", 20); + let second_object = world.ticket(&repo, "3lubrptx57d22", &acc("olaren"), 2, "squatting", 20); index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("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, &issue_collection()), + index.social_of_kind(&repo, &ticket_collection()), Resolved::Ready(vec![first_object]), "lookup by address, and one by kind, agree, loser doesn't answer listings" ); @@ -2777,10 +2557,10 @@ fn two_objects_claiming_one_address_leave_one_reachable() { ); assert_eq!( index.social_address_of(&repo, first_object), - Resolved::Ready(Some(issue_at("3lubrptx57d22"))) + Resolved::Ready(Some(ticket_at("3lubrptx57d22"))) ); assert_eq!( - index.next_subject_number(&repo, &issue_collection()), + index.next_subject_number(&repo, &ticket_collection()), Resolved::Ready(SubjectNumber::new(3)), "still keeping its number, the loser of an address mustn't answer any listing, or the \ next create numbers two objects the same" @@ -2793,34 +2573,34 @@ fn two_objects_claiming_one_address_leave_one_reachable() { &cold, &repo, &[first_object, second_object], - &[issue_at("3lubrptx57d22")], + &[ticket_at("3lubrptx57d22")], ); } #[test] fn refreshing_one_object_leaves_other_answers_where_cold_rebuild_puts_them() { let (world, repo, incumbent, index) = folded_kelp(); - let untouched = world.issue(&repo, "3lubrptx57d33", &acc("nel"), 2, "shell", 20); + let untouched = world.ticket(&repo, "3lubrptx57d33", &acc("nel"), 2, "shell", 20); index.refresh_social(&repo).unwrap(); - let before = index.social_of_kind(&repo, &issue_collection()); + let before = index.social_of_kind(&repo, &ticket_collection()); - let loser = world.issue(&repo, "3lubrptx57d22", &acc("olaren"), 3, "squatting", 30); + let loser = world.ticket(&repo, "3lubrptx57d22", &acc("olaren"), 3, "squatting", 30); index - .refresh_social_object(&repo, &issue_type_name(), loser) + .refresh_social_object(&repo, &ticket_type_name(), loser) .unwrap(); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d22")), + index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Ready(Some(incumbent)), "folding latecomer alone doesn't hand it incumbent's address" ); assert_eq!( - index.social_of_kind(&repo, &issue_collection()), + index.social_of_kind(&repo, &ticket_collection()), before, "contested arrival leaves listing alone, and sibling's order where it was" ); assert_eq!( - index.social_object_at(&repo, &issue_at("3lubrptx57d33")), + index.social_object_at(&repo, &ticket_at("3lubrptx57d33")), Resolved::Ready(Some(untouched)), "and object nobody contested answers for itself" ); @@ -2832,7 +2612,7 @@ fn refreshing_one_object_leaves_other_answers_where_cold_rebuild_puts_them() { &cold, &repo, &[incumbent, untouched, loser], - &[issue_at("3lubrptx57d22"), issue_at("3lubrptx57d33")], + &[ticket_at("3lubrptx57d22"), ticket_at("3lubrptx57d33")], ); } @@ -2843,7 +2623,7 @@ fn a_change_that_opens_no_object_still_costs_the_repository_its_bytes() { panic!("Projection must be built"); }; - world.open_issue( + world.open_ticket( &repo, &erased(&acc("nel"), note_record("kelp").1, at(20)), Record::Tombstone, @@ -2859,7 +2639,7 @@ fn a_change_that_opens_no_object_still_costs_the_repository_its_bytes() { that skipped them wouldn't bound anything" ); assert_eq!( - index.social_of_kind(&repo, &issue_collection()), + index.social_of_kind(&repo, &ticket_collection()), Resolved::Ready(vec![object]), "it shouldn't read as an object either" ); diff --git a/knot2/crates/knot-xrpc/tests/atproto_interop.rs b/knot2/crates/knot-xrpc/tests/atproto_interop.rs index 974699b4c..f3aaf7a63 100644 --- a/knot2/crates/knot-xrpc/tests/atproto_interop.rs +++ b/knot2/crates/knot-xrpc/tests/atproto_interop.rs @@ -1,8 +1,8 @@ mod common; use common::{ - FIXTURE_COLLECTION as COLLECTION, OWNER, World, commit_file, empty_repo, http_push, - serve_pushes, sh_git, + FIXTURE_COLLECTION as COLLECTION, OWNER, World, commit_file, empty_repo, enable_chain, + http_push, seeded_feature_branch, serve_pushes, sh_git, }; const KNOT_DID: &str = "did:web:knot.nel.pet"; @@ -56,54 +56,6 @@ async fn indigo_client_reads_and_verifies_whole_knot() { .await; } -#[tokio::test] -async fn indigo_client_reads_and_verifies_knot_owned_issue() { - let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "conch"); - common::enable_chain(&world, &did); - let (status, written) = common::post_as( - &world, - "/xrpc/com.atproto.repo.createRecord", - OWNER, - did.as_str(), - serde_json::json!({ - "repo": did.as_str(), - "collection": "sh.tangled.repo.issue", - "record": { - "$type": "sh.tangled.repo.issue", - "repo": did.as_str(), - "title": "kelp", - "body": "a forest", - "createdAt": "2026-09-11T00:00:00Z" - } - }), - ) - .await; - assert_eq!(status, 200, "{written}"); - let uri = written["uri"].as_str().unwrap(); - let rkey = uri.rsplit('/').next().unwrap(); - let addr = common::serve(&world).await; - - go_test( - "TestKnotServesIssueUnderRepositoryDid", - &[ - ("KNOT_TEST_BASE_URL", format!("http://{addr}")), - ("KNOT_TEST_REPO_DID", did.as_str().to_owned()), - ( - "KNOT_TEST_REPO_DID_DOC", - common::repo_did_document(&world, &did).to_string(), - ), - ("KNOT_TEST_ISSUE_RKEY", rkey.to_owned()), - ( - "KNOT_TEST_ISSUE_CID", - written["cid"].as_str().unwrap().to_owned(), - ), - ("KNOT_TEST_EDITOR", OWNER.to_owned()), - ], - ) - .await; -} - #[tokio::test] async fn an_indigo_client_consumes_the_firehose() { let world = World::administered(); @@ -153,3 +105,30 @@ async fn a_knotfeed_consumer_reads_the_ref_records() { ) .await; } + +#[tokio::test] +async fn indigo_client_reads_tickets_and_patches_in_the_org_tangled_shapes() { + let world = World::new(); + let (did, base, head) = seeded_feature_branch(&world, "conch"); + enable_chain(&world, &did); + let (ticket_rkey, ticket_cid) = common::create_ticket(&world, &did, "kelp", "a forest").await; + + let (patch_rkey, patch_cid) = + common::create_patch(&world, &did, &ticket_rkey, &base, &head).await; + + let addr = common::serve(&world).await; + go_test( + "TestKnotServesTicketsAndPatchesInTheOrgTangledShapes", + &[ + ("KNOT_TEST_BASE_URL", format!("http://{addr}")), + ("KNOT_TEST_REPO_DID", did.as_str().to_owned()), + ("KNOT_TEST_TICKET_RKEY", ticket_rkey.to_string()), + ("KNOT_TEST_TICKET_CID", ticket_cid), + ("KNOT_TEST_TICKET_TITLE", "kelp".to_owned()), + ("KNOT_TEST_EDITOR", OWNER.to_owned()), + ("KNOT_TEST_PATCH_RKEY", patch_rkey.to_string()), + ("KNOT_TEST_PATCH_CID", patch_cid), + ], + ) + .await; +} diff --git a/knot2/crates/knot-xrpc/tests/blob_properties.rs b/knot2/crates/knot-xrpc/tests/blob_properties.rs index 89b1690f5..ca1070309 100644 --- a/knot2/crates/knot-xrpc/tests/blob_properties.rs +++ b/knot2/crates/knot-xrpc/tests/blob_properties.rs @@ -48,18 +48,6 @@ fn blob_cid(bytes: &[u8]) -> BlobCid { BlobCid::from_digest(Sha256Digest::hash(bytes)) } -fn rkey_of(response: &serde_json::Value) -> RecordRkey { - RecordRkey::new( - response["uri"] - .as_str() - .expect("the write response has a URI") - .rsplit('/') - .next() - .expect("a URI ends in the rkey"), - ) - .expect("a URI's tail is an rkey") -} - fn commit_cid_of(response: &serde_json::Value) -> cid::Cid { cid::Cid::try_from( response["cid"] @@ -102,14 +90,51 @@ struct Model { impl Model { fn blob_json(&self, token: u16) -> serde_json::Value { - json!({ - "$type": "blob", + let blob = json!({ "ref": { "$link": blob_cid(&self.uploaded[&token]).to_string() }, "mimeType": mime_of(token), "size": self.uploaded[&token].len(), + }); + match mime_of(token) { + "video/mp4" => json!({ + "$type": "org.tangled.markup.embed.video", + "video": blob + }), + _ => json!({ + "$type": "org.tangled.markup.embed.image", + "image": blob + }), + } + } + + fn ticket_json(&self, repo: &str, title: &str, token: u16) -> serde_json::Value { + json!({ + "$type": "org.tangled.track.ticket", + "repo": repo, + "title": title, + "body": { + "$type": "org.tangled.markup.markdown", + "text": title, + "blobs": [self.blob_json(token)], + }, + "createdAt": "2026-10-01T00:00:00Z", }) } + fn embed_cid(blob: &serde_json::Value) -> BlobCid { + let inner = if blob["image"].is_null() { + &blob["video"] + } else { + &blob["image"] + }; + common::blob_cid_of(&inner["ref"]["$link"]) + } + + fn embeddable(token: u16) -> bool { + let mime = mime_of(token); + mime == "video/mp4" || mime.starts_with("image/") + } + fn at(&self, index: usize) -> Option<(&RecordRkey, &LiveRecord)> { self.live.get(index).map(|(rkey, record)| (rkey, record)) } @@ -182,7 +207,7 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { model.uploaded.entry(token).or_insert(body); } Op::Create(token) => { - if !model.uploaded.contains_key(&token) { + if !model.uploaded.contains_key(&token) || !Model::embeddable(token) { return Ok(model); } let (status, written) = post_as( @@ -192,20 +217,14 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { repo, json!({ "repo": repo, - "collection": "sh.tangled.repo.issue", - "record": { - "$type": "sh.tangled.repo.issue", - "repo": repo, - "title": "kelp under test", - "blobs": [model.blob_json(token)], - "createdAt": "2026-10-01T00:00:00Z", - }, + "collection": "org.tangled.track.ticket", + "record": model.ticket_json(repo, "kelp under test", token), }), ) .await; prop_assert_eq!(status, StatusCode::OK, "{}", written); model.live.push(( - rkey_of(&written), + common::rkey_of(&written), LiveRecord { swap: commit_cid_of(&written), rev: common::commit_rev_of(&written), @@ -215,10 +234,10 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { } Op::Edit(index, token) => { let (rkey, swap) = match model.at(index as usize) { - Some((rkey, record)) => (rkey.clone(), record.swap.clone()), + Some((rkey, record)) => (rkey.clone(), record.swap), None => return Ok(model), }; - if !model.uploaded.contains_key(&token) { + if !model.uploaded.contains_key(&token) || !Model::embeddable(token) { return Ok(model); } let (status, written) = post_as( @@ -228,21 +247,15 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { repo, json!({ "repo": repo, - "collection": "sh.tangled.repo.issue", + "collection": "org.tangled.track.ticket", "rkey": rkey.to_string(), "swapRecord": swap.to_string(), - "record": { - "$type": "sh.tangled.repo.issue", - "repo": repo, - "title": "kelp under test, again", - "blobs": [model.blob_json(token)], - "createdAt": "2026-10-01T00:00:00Z", - }, + "record": model.ticket_json(repo, "kelp under test, again", token), }), ) .await; prop_assert_eq!(status, StatusCode::OK, "{}", written); - let replaced = rkey_of(&written); + let replaced = common::rkey_of(&written); if let Some((_, record)) = model.live.iter_mut().find(|(rkey, _)| *rkey == replaced) { @@ -253,7 +266,7 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { } Op::Delete(index) => { let (rkey, swap) = match model.at(index as usize) { - Some((rkey, record)) => (rkey.clone(), record.swap.clone()), + Some((rkey, record)) => (rkey.clone(), record.swap), None => return Ok(model), }; let (status, response) = post_as( @@ -263,7 +276,7 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { repo, json!({ "repo": repo, - "collection": "sh.tangled.repo.issue", + "collection": "org.tangled.track.ticket", "rkey": rkey.to_string(), "swapRecord": swap.to_string(), }), @@ -319,12 +332,12 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { futures::stream::iter(&model.live) .map(Ok) .try_for_each(|(rkey, record)| { - let (world, repo) = (&world, repo); + let (world, repo, uploaded) = (&world, &repo, &model.uploaded); async move { let (status, _headers, response) = get( world, &format!( - "/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=sh.tangled.repo.issue&rkey={rkey}" + "/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=org.tangled.track.ticket&rkey={rkey}" ), ) .await; @@ -332,10 +345,10 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { serde_json::from_slice(&response).unwrap_or_default(); prop_assert_eq!(status, StatusCode::OK, "{}", response); prop_assert_eq!( - response["value"]["blobs"] + response["value"]["body"]["blobs"] .as_array() - .map(|blobs| blobs.len()), - Some(1), + .map(|blobs| blobs.iter().map(Model::embed_cid).collect::>()), + Some(vec![blob_cid(&uploaded[&record.blob])]), "{} has blob {} until it is deleted.", rkey, record.blob diff --git a/knot2/crates/knot-xrpc/tests/blobs.rs b/knot2/crates/knot-xrpc/tests/blobs.rs index 609b94467..dc7c94a80 100644 --- a/knot2/crates/knot-xrpc/tests/blobs.rs +++ b/knot2/crates/knot-xrpc/tests/blobs.rs @@ -80,12 +80,20 @@ async fn create_with_blob( ) -> (StatusCode, serde_json::Value) { let record = json!({ "repo": repo.as_str(), - "collection": "sh.tangled.repo.issue", + "collection": "org.tangled.track.ticket", "record": { - "$type": "sh.tangled.repo.issue", + "$type": "org.tangled.track.ticket", "repo": repo.as_str(), "title": "kelp with a picture", - "blobs": [blob], + "body": { + "$type": "org.tangled.markup.markdown", + "text": "a forest", + "blobs": [json!({ + "$type": "org.tangled.markup.embed.image", + "image": blob + })], + }, + "state": "open", "createdAt": "2026-09-30T00:00:00Z", }, }); @@ -139,7 +147,7 @@ async fn attachment_round_trips_from_upload_to_getblob_and_listblobs() { let (status, _headers, _bytes) = get( &world, - &format!("/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=sh.tangled.repo.issue&rkey={rkey}"), + &format!("/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=org.tangled.track.ticket&rkey={rkey}"), ) .await; assert_eq!(status, StatusCode::OK); @@ -559,7 +567,7 @@ async fn listblobs_keeps_shared_blob_at_latest_referencing_record() { } #[tokio::test] -async fn pull_lists_blobs_as_issues_do() { +async fn pull_attachments_live_on_the_ticket_not_the_patch() { let world = World::new(); let (did, base, head) = seeded_feature_branch(&world, "whelk"); enable_chain(&world, &did); @@ -567,44 +575,37 @@ async fn pull_lists_blobs_as_issues_do() { let (status, uploaded) = upload(&world, &did, PNG).await; assert_eq!(status, StatusCode::OK, "{uploaded}"); - let (status, written) = post_as( + let (status, ticket) = + create_with_blob(&world, Some(OWNER), &did, uploaded["blob"].clone()).await; + assert_eq!(status, StatusCode::OK, "{ticket}"); + let ticket_rkey = common::rkey_of(&ticket); + + common::create_patch(&world, &did, &ticket_rkey, &base, &head).await; + + let (status, listed) = get_json( &world, - "/xrpc/com.atproto.repo.createRecord", - OWNER, - &repo, - json!({ - "repo": repo, - "collection": "sh.tangled.repo.pull", - "record": { - "$type": "sh.tangled.repo.pull", - "target": { "repo": repo, "branch": "main" }, - "title": "kelp with a picture", - "rounds": [], - "versions": [{ "base": base.to_hex(), "head": head.to_hex() }], - "blobs": [uploaded["blob"].clone()], - "createdAt": "2026-10-01T00:00:00Z", - }, - }), + &format!("/xrpc/com.atproto.sync.listBlobs?did={repo}"), ) .await; - assert_eq!(status, StatusCode::OK, "{written}"); - let rkey = written["uri"] - .as_str() - .unwrap() - .rsplit('/') - .next() - .unwrap() - .to_owned(); + assert_eq!(status, StatusCode::OK, "{listed}"); + assert_eq!( + common::listed_cids(&listed) + .into_iter() + .filter(|cid| *cid == blob_cid_link(PNG)) + .count(), + 1, + "The ticket that fronts the pull lists its blob: {listed}" + ); - let (status, record) = get_json( + let (status, served) = get_json( &world, - &format!("/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=sh.tangled.repo.pull&rkey={rkey}"), + &format!("/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=org.tangled.track.ticket&rkey={ticket_rkey}"), ) .await; - assert_eq!(status, StatusCode::OK, "{record}"); + assert_eq!(status, StatusCode::OK, "{served}"); assert_eq!( - common::blob_cid_of(&record["value"]["blobs"][0]["ref"]["$link"]), + common::blob_cid_of(&served["value"]["body"]["blobs"][0]["image"]["ref"]["$link"]), blob_cid_link(PNG), - "The served pull: {record}" + "The ticket record lists the exact blob it was written with: {served}" ); } diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index e070d9d42..b36fef9e9 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -136,8 +136,8 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, AuthorName, Email, KnotHostname, KnotId, ObjectFormat, Oid, OwnerDid, RepoDid, - RepoName, RepoRkey, UnixSeconds, + AccountDid, AuthorName, Email, KnotHostname, KnotId, ObjectFormat, Oid, OwnerDid, RecordRkey, + RepoDid, RepoName, RepoRkey, UnixSeconds, }; use knot_xrpc::{ ArchiveLimit, Budgets, ByteLimits, CobLocks, GlobalQuota, LimitConfig, PerActorQuota, @@ -146,6 +146,8 @@ use knot_xrpc::{ pub const KNOT_HOST: &str = "knot.nel.pet"; pub const FIXTURE_COLLECTION: &str = "sh.tangled.test.record"; +pub const TICKET_COLLECTION: &str = "org.tangled.track.ticket"; +pub const PATCH_COLLECTION: &str = "org.tangled.review.patch"; pub fn fixture_collection() -> knot_types::RecordCollection { knot_types::RecordCollection::new(FIXTURE_COLLECTION).unwrap() @@ -172,6 +174,83 @@ pub fn commit_rev_of(response: &serde_json::Value) -> jacquard_common::types::ti ) .expect("the rev parses as a TID") } + +pub fn rkey_of(response: &serde_json::Value) -> RecordRkey { + RecordRkey::new( + response["uri"] + .as_str() + .expect("the write response has a URI") + .rsplit('/') + .next() + .expect("a URI ends in the rkey"), + ) + .expect("a URI's tail is an rkey") +} + +pub async fn create_ticket( + world: &World, + repo: &RepoDid, + title: &str, + body: &str, +) -> (RecordRkey, String) { + let (status, written) = post_as( + world, + "/xrpc/com.atproto.repo.createRecord", + OWNER, + repo.as_str(), + serde_json::json!({ + "repo": repo.as_str(), + "collection": TICKET_COLLECTION, + "record": { + "$type": TICKET_COLLECTION, + "repo": repo.as_str(), + "title": title, + "body": body, + "state": "open", + "createdAt": "2026-09-11T00:00:00Z" + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{written}"); + ( + rkey_of(&written), + written["cid"].as_str().unwrap().to_owned(), + ) +} + +pub async fn create_patch( + world: &World, + repo: &RepoDid, + ticket: &RecordRkey, + base: &Oid, + head: &Oid, +) -> (RecordRkey, String) { + let (status, written) = post_as( + world, + "/xrpc/com.atproto.repo.createRecord", + OWNER, + repo.as_str(), + serde_json::json!({ + "repo": repo.as_str(), + "collection": PATCH_COLLECTION, + "record": { + "$type": PATCH_COLLECTION, + "ticket": ticket, + "base": { "oid": base.to_hex() }, + "head": { "oid": head.to_hex() }, + "createdAt": "2026-09-11T00:00:00Z" + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{written}"); + ( + rkey_of(&written), + written["cid"].as_str().unwrap().to_owned(), + ) +} + #[derive(serde::Serialize)] struct FixtureRecord { #[serde(rename = "$type")] diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 144772cc6..34e722ccd 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -2013,9 +2013,11 @@ async fn repo_error_outranks_paging_in_any_param_order() { #[tokio::test] async fn service_metadata_endpoints_answer() { let world = World::new(); - let wire = get_json(&world, "/xrpc/sh.tangled.knot.version").await; + let wire = get_json(&world, "/xrpc/org.tangled.knot.describeKnot").await; assert_eq!(wire["version"], "v1.15.0"); + assert_eq!(wire["did"], world.state.knot_did.as_str()); let served = [ + Capability::Tangled1, Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, -- 2.51.2