From bdb7043a1975b57c3dbdb9454fdde589ee0cf5ff Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 2 Oct 2026 10:32:30 +0300 Subject: [PATCH] knot2: `sh.tangled` -> `org.tangled`, `issue` -> `ticket` Lewis: May this revision serve well! --- Cargo.lock | 1 - crates/knot-capability/src/lib.rs | 4 + crates/lexicons/Cargo.toml | 3 + knot2/crates/knot-atproto/Cargo.toml | 1 - knot2/crates/knot-atproto/src/lib.rs | 247 +-------- knot2/crates/knot-atproto/src/test_support.rs | 8 - knot2/crates/knot-bench/benches/projection.rs | 2 +- knot2/crates/knot-bench/src/fixtures.rs | 71 +-- knot2/crates/knot-cobs/src/comment.rs | 9 +- knot2/crates/knot-cobs/src/issue.rs | 133 ----- knot2/crates/knot-cobs/src/lib.rs | 18 +- knot2/crates/knot-cobs/src/pull.rs | 105 +--- knot2/crates/knot-cobs/src/reaction.rs | 11 +- knot2/crates/knot-cobs/src/ticket.rs | 79 +++ knot2/crates/knot-index/src/lib.rs | 2 +- knot2/crates/knot-index/src/social.rs | 95 ++-- knot2/crates/knot-lexicons/Cargo.toml | 4 + knot2/crates/knot-record/src/chain.rs | 89 ++-- knot2/crates/knot-record/src/comment.rs | 178 +++---- knot2/crates/knot-record/src/git_refs.rs | 10 +- knot2/crates/knot-record/src/issue.rs | 355 ------------- knot2/crates/knot-record/src/lib.rs | 40 +- knot2/crates/knot-record/src/pull.rs | 496 +++--------------- knot2/crates/knot-record/src/reaction.rs | 56 +- knot2/crates/knot-record/src/ticket.rs | 360 +++++++++++++ knot2/crates/knot-sim/src/realdata.rs | 2 +- knot2/crates/knot-sim/src/workload.rs | 2 +- knot2/crates/knot-xrpc/src/blobs.rs | 14 +- knot2/crates/knot-xrpc/src/lib.rs | 2 +- knot2/crates/knot-xrpc/src/service.rs | 8 +- knot2/interop/atproto_issue_test.go | 97 ---- knot2/interop/atproto_reads_test.go | 32 ++ knot2/interop/atproto_ticket_test.go | 173 ++++++ .../org/tangled/review/unspecced/round.json | 2 +- lexicons/org/tangled/snippet/string.json | 4 +- 35 files changed, 1041 insertions(+), 1672 deletions(-) delete mode 100644 knot2/crates/knot-cobs/src/issue.rs create mode 100644 knot2/crates/knot-cobs/src/ticket.rs delete mode 100644 knot2/crates/knot-record/src/issue.rs create mode 100644 knot2/crates/knot-record/src/ticket.rs delete mode 100644 knot2/interop/atproto_issue_test.go create mode 100644 knot2/interop/atproto_ticket_test.go diff --git a/Cargo.lock b/Cargo.lock index e032e551b..b82dca4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5040,7 +5040,6 @@ dependencies = [ "k256", "knot-cache", "knot-lexicons", - "knot-record", "knot-runtime", "knot-types", "proptest", diff --git a/crates/knot-capability/src/lib.rs b/crates/knot-capability/src/lib.rs index 3223d8570..54ae56956 100644 --- a/crates/knot-capability/src/lib.rs +++ b/crates/knot-capability/src/lib.rs @@ -1,5 +1,6 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum Capability { + Tangled1, KnotAcl, RepoDidInput, KeepCommit, @@ -8,6 +9,7 @@ pub enum Capability { impl Capability { const ALL: &'static [Capability] = &[ + Capability::Tangled1, Capability::KnotAcl, Capability::RepoDidInput, Capability::KeepCommit, @@ -15,6 +17,7 @@ impl Capability { ]; pub const SERVED: &'static [Capability] = &[ + Capability::Tangled1, Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, @@ -22,6 +25,7 @@ impl Capability { pub const fn token(self) -> &'static str { match self { + Capability::Tangled1 => "tangled1.0", Capability::KnotAcl => "knot-acl", Capability::RepoDidInput => "repo-did-input", Capability::KeepCommit => "knot-keepcommit", diff --git a/crates/lexicons/Cargo.toml b/crates/lexicons/Cargo.toml index d3cf39c32..7246fcdb9 100644 --- a/crates/lexicons/Cargo.toml +++ b/crates/lexicons/Cargo.toml @@ -30,3 +30,6 @@ sh_tangled = ["com_atproto"] # org.tangled.temp.search.searchCode#fileResult.repo refs sh.tangled.repo.defs#repoViewBasic org_tangled = ["sh_tangled"] streaming = ["jacquard-common/websocket"] + +[lints.clippy] +unnecessary_lazy_evaluations = "allow" diff --git a/knot2/crates/knot-atproto/Cargo.toml b/knot2/crates/knot-atproto/Cargo.toml index c6df4a1de..9f967b783 100644 --- a/knot2/crates/knot-atproto/Cargo.toml +++ b/knot2/crates/knot-atproto/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] knot-types = { workspace = true } -knot-record = { workspace = true } knot-runtime = { workspace = true } knot-cache = { workspace = true } cid = { workspace = true } diff --git a/knot2/crates/knot-atproto/src/lib.rs b/knot2/crates/knot-atproto/src/lib.rs index 62b2c2ae6..10073f19e 100644 --- a/knot2/crates/knot-atproto/src/lib.rs +++ b/knot2/crates/knot-atproto/src/lib.rs @@ -66,8 +66,8 @@ use knot_runtime::{ UnixMicros, }; use knot_types::{ - AccountDid, Collection, Handle, HttpStatus, KnotId, Nsid, OfferedKey, RecordRkey, RepoDid, - RepoRkey, Rkey, UnixSeconds, + AccountDid, Collection, Handle, HttpStatus, KnotId, Nsid, OfferedKey, RepoDid, RepoRkey, Rkey, + UnixSeconds, }; use pubkeys::Cursor; use serde::{Deserialize, Serialize}; @@ -83,10 +83,6 @@ fn repo_collection() -> Nsid { const PUBKEY_MAX_PAGES: usize = 8; const PUBKEY_TTL: Duration = Duration::from_secs(30); const MAX_PUBKEY_CACHE_BYTES: u64 = 1 << 20; -const DEF_TTL: Duration = Duration::from_secs(30); -const MAX_DEF_CACHE_BYTES: u64 = 1 << 20; -const MAX_REMOTE_DEF_BYTES: usize = 64 * 1024; -const DEF_CACHE_ENTRY_OVERHEAD: u64 = 128; const PUBKEY_CACHE_ENTRY_OVERHEAD: u64 = 128; const PUBKEY_CACHE_KEY_OVERHEAD: u64 = 192; const MAX_IDENTITY_CACHE: usize = 4096; @@ -130,15 +126,6 @@ pub enum AtprotoError { owner: AccountDid, status: HttpStatus, }, - #[error( - "getRecord for label def {rkey} of {owner} returned a body that doesn't parse as a def: \ - {reason}" - )] - MalformedDef { - owner: AccountDid, - rkey: RecordRkey, - reason: String, - }, #[error("Pointer record couldn't be encoded: {0}")] PointerEncode(String), #[error("putRecord response isn't a valid receipt: {0}")] @@ -177,7 +164,6 @@ impl AtprotoError { | AtprotoError::PlcFetch { .. } | AtprotoError::PutRecord { .. } | AtprotoError::GetRecord { .. } - | AtprotoError::MalformedDef { .. } | AtprotoError::PointerEncode(_) | AtprotoError::MalformedReceipt(_) => false, } @@ -210,15 +196,6 @@ pub enum ClaimedKeys { Unread(Arc), } -#[derive(Clone)] -pub enum RemoteDef { - Read { - def: knot_record::label::Def, - bytes: usize, - }, - Unread(Arc), -} - fn claimed_weight(cached: &Cached) -> Weight { Weight::new(match &cached.resolution { ClaimedKeys::Published(keys) => keys @@ -229,12 +206,6 @@ fn claimed_weight(cached: &Cached) -> Weight { ClaimedKeys::Unread(_) => PUBKEY_CACHE_ENTRY_OVERHEAD, }) } -fn def_weight(cached: &Cached) -> Weight { - Weight::new(match &cached.resolution { - RemoteDef::Read { bytes, .. } => *bytes as u64 + DEF_CACHE_ENTRY_OVERHEAD, - RemoteDef::Unread(_) => DEF_CACHE_ENTRY_OVERHEAD, - }) -} pub struct Atproto { http: H, @@ -245,7 +216,6 @@ pub struct Atproto { identities: MokaFuture>, handles: MokaFuture>, claimed: MokaFuture>, - defs: MokaFuture>, seen_jti: Expiring<(AccountDid, JwtNonce), AccountDid, ()>, } @@ -260,7 +230,6 @@ impl Atproto { identities: MokaFuture::by_count(EntryCount::new(MAX_IDENTITY_CACHE as u64)), handles: MokaFuture::by_count(EntryCount::new(MAX_IDENTITY_CACHE as u64)), claimed: MokaFuture::by_weight(Weight::new(MAX_PUBKEY_CACHE_BYTES), claimed_weight), - defs: MokaFuture::by_weight(Weight::new(MAX_DEF_CACHE_BYTES), def_weight), seen_jti: Expiring::new(Quotas { per_group: GroupQuota::new(MAX_JTI_PER_ISSUER), total: TotalQuota::new(MAX_SEEN_JTI), @@ -589,99 +558,6 @@ impl Atproto { } } - pub async fn remote_def(&self, wanted: &knot_record::label::DefRef) -> RemoteDef { - let now = self.clock.now_unix_micros(); - let filled = self - .defs - .get_or_fill_if( - wanted.clone(), - |cached: &Cached| cached.expires_at <= now, - self.fill_def(wanted, now), - ) - .await; - filled.value.resolution - } - - async fn fill_def( - &self, - wanted: &knot_record::label::DefRef, - now: UnixMicros, - ) -> Cached { - match self.fetch_def(wanted).await { - Ok((value, bytes)) => match knot_record::label::Def::parse(value) { - Ok(def) => Cached { - resolution: RemoteDef::Read { def, bytes }, - expires_at: expires(now, DEF_TTL), - }, - Err(reason) => Cached { - resolution: RemoteDef::Unread(Arc::new(AtprotoError::MalformedDef { - owner: AccountDid::from(wanted.repo().clone()), - rkey: wanted.rkey().clone(), - reason: reason.to_string(), - })), - expires_at: expires(now, NEGATIVE_TTL), - }, - }, - Err(error) => Cached { - expires_at: if error.is_transient() { - now - } else { - expires(now, NEGATIVE_TTL) - }, - resolution: RemoteDef::Unread(Arc::new(error)), - }, - } - } - - async fn fetch_def( - &self, - wanted: &knot_record::label::DefRef, - ) -> Result<(serde_json::Value, usize), AtprotoError> { - let owner = AccountDid::from(wanted.repo().clone()); - let rkey = wanted.rkey().clone(); - let cached = self.identity(&owner).await?; - let collection: Nsid = Nsid::new_static(knot_record::label::LABEL_DEF_COLLECTION) - .expect("Literal NSID parses"); - let url = get_record_url(&cached.value.pds, &owner, &collection, rkey.as_str())?; - resolve::guard_fetch_url(&url)?; - let response = self.http.execute(HttpRequest::get(url)).await?; - if !response.status.is_success() { - return Err(AtprotoError::GetRecord { - owner: owner.clone(), - status: HttpStatus::from(response.status), - }); - } - if response.body.len() > MAX_REMOTE_DEF_BYTES { - return Err(AtprotoError::MalformedDef { - owner: owner.clone(), - rkey: rkey.clone(), - reason: format!( - "the body is {} bytes, over the {MAX_REMOTE_DEF_BYTES} byte limit for \ - a label def", - response.body.len() - ), - }); - } - let bytes = response.body.len(); - let decoded: serde_json::Value = - serde_json::from_slice(response.body.as_ref()).map_err(|error| { - AtprotoError::MalformedDef { - owner: owner.clone(), - rkey: rkey.clone(), - reason: error.to_string(), - } - })?; - decoded - .get("value") - .cloned() - .map(|value| (value, bytes)) - .ok_or_else(|| AtprotoError::MalformedDef { - owner: owner.clone(), - rkey: rkey.clone(), - reason: "the response is missing a value field".to_owned(), - }) - } - async fn read_record( &self, identity: &Identity, @@ -1567,125 +1443,6 @@ mod tests { ); } - #[tokio::test] - async fn remote_def_costs_one_getrecord_per_ttl() { - let signing = signer(11); - let reads = Arc::new(AtomicUsize::new(0)); - let counter = reads.clone(); - let def = serde_json::json!({ - "uri": "at://did:plc:squid/sh.tangled.label.definition/wontfix", - "cid": "bafyreihdwdhef9h1o6xtmxpgpkwp0shftvtjhu2suxcbhelperphpriljku", - "value": { - "$type": "sh.tangled.label.definition", - "name": "wontfix", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "valueType": {"type": "null", "format": "any"} - } - }); - let http = FakeHttp::new(move |request| { - if request.url.host_str() == Some("plc.directory") { - return Ok(ok(squid_doc(&signing))); - } - assert!(request.url.path().ends_with("com.atproto.repo.getRecord")); - assert!(request.url.query().unwrap().contains("rkey=wontfix")); - counter.fetch_add(1, Ordering::SeqCst); - Ok(ok(Bytes::from(serde_json::to_vec(&def).unwrap()))) - }); - let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); - - let wontfix = wanted_def("wontfix"); - let first = atproto.remote_def(&wontfix).await; - assert!( - matches!(&first, RemoteDef::Read { def, .. } if def.name().as_str() == "wontfix"), - "Def comes back read" - ); - let second = atproto.remote_def(&wontfix).await; - assert!(matches!(&second, RemoteDef::Read { .. })); - assert_eq!( - reads.load(Ordering::SeqCst), - 1, - "several operands on one def share a single getRecord" - ); - - atproto.clock.advance(DEF_TTL + Duration::from_micros(1)); - let refreshed = atproto.remote_def(&wontfix).await; - assert!(matches!(&refreshed, RemoteDef::Read { .. })); - assert_eq!(reads.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn unreadable_def_caches_unread_for_the_negative_ttl() { - let signing = signer(12); - let reads = Arc::new(AtomicUsize::new(0)); - let counter = reads.clone(); - let absent = FakeHttp::new(move |request| { - if request.url.host_str() == Some("plc.directory") { - return Ok(ok(squid_doc(&signing))); - } - counter.fetch_add(1, Ordering::SeqCst); - Ok(status(StatusCode::NOT_FOUND, Bytes::new())) - }); - let atproto = Atproto::new(absent, clock(), knot_did(KNOT), plc()); - let gone = wanted_def("gone"); - let first = atproto.remote_def(&gone).await; - assert!(matches!(&first, RemoteDef::Unread(_))); - let second = atproto.remote_def(&gone).await; - assert!(matches!(&second, RemoteDef::Unread(_))); - assert_eq!( - reads.load(Ordering::SeqCst), - 1, - "an absent def caches unread for the negative TTL" - ); - - let bloat = serde_json::json!({ - "uri": "at://did:plc:squid/sh.tangled.label.definition/bloat", - "cid": "bafyreihdwdhef9h1o6xtmxpgpkwp0shftvtjhu2suxcbhelperphpriljku", - "value": { - "$type": "sh.tangled.label.definition", - "name": "bloat", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "valueType": { - "type": "string", - "format": "any", - "enum": (0..8192) - .map(|index| format!("wontfix-{index}")) - .collect::>() - } - } - }); - let signing = signer(13); - let reads = Arc::new(AtomicUsize::new(0)); - let counter = reads.clone(); - let serving = FakeHttp::new(move |request| { - if request.url.host_str() == Some("plc.directory") { - return Ok(ok(squid_doc(&signing))); - } - counter.fetch_add(1, Ordering::SeqCst); - Ok(ok(Bytes::from(serde_json::to_vec(&bloat).unwrap()))) - }); - let atproto = Atproto::new(serving, clock(), knot_did(KNOT), plc()); - let bloat = wanted_def("bloat"); - let first = atproto.remote_def(&bloat).await; - let RemoteDef::Unread(error) = &first else { - panic!("An oversized def comes back unread"); - }; - assert!( - matches!(&**error, AtprotoError::MalformedDef { .. }), - "the unread error for an oversized body is MalformedDef" - ); - let second = atproto.remote_def(&bloat).await; - assert!(matches!(&second, RemoteDef::Unread(_))); - assert_eq!( - reads.load(Ordering::SeqCst), - 1, - "the unread error caches for the negative TTL" - ); - } - #[tokio::test] async fn a_refused_claim_read_is_cached_and_an_outage_is_retried() { let refusal = Arc::new(Mutex::new(StatusCode::BAD_REQUEST)); diff --git a/knot2/crates/knot-atproto/src/test_support.rs b/knot2/crates/knot-atproto/src/test_support.rs index b8345e1db..a33e96765 100644 --- a/knot2/crates/knot-atproto/src/test_support.rs +++ b/knot2/crates/knot-atproto/src/test_support.rs @@ -27,14 +27,6 @@ pub(crate) fn repo_did(value: &str) -> RepoDid { RepoDid::new(value).unwrap() } -pub(crate) fn wanted_def(rkey: &str) -> knot_record::label::DefRef { - knot_record::label::DefRef::parse( - &knot_types::AtUri::new_owned(format!("at://{SQUID}/sh.tangled.label.definition/{rkey}")) - .unwrap(), - ) - .unwrap() -} - pub(crate) fn owner_did(value: &str) -> OwnerDid { OwnerDid::new(value).unwrap() } diff --git a/knot2/crates/knot-bench/benches/projection.rs b/knot2/crates/knot-bench/benches/projection.rs index 69bb6d041..eabb16590 100644 --- a/knot2/crates/knot-bench/benches/projection.rs +++ b/knot2/crates/knot-bench/benches/projection.rs @@ -55,7 +55,7 @@ fn social_boot(bencher: Bencher, issues: u32) { vec![ RefName::new(format!( "{CHECKPOINTS_PREFIX}{}/{}", - knot_record::issue::ISSUE_COLLECTION, + knot_record::ticket::TICKET_COLLECTION, edited.oid().to_hex() )) .expect("checkpoint ref name") diff --git a/knot2/crates/knot-bench/src/fixtures.rs b/knot2/crates/knot-bench/src/fixtures.rs index d2edfcac8..1fb48d944 100644 --- a/knot2/crates/knot-bench/src/fixtures.rs +++ b/knot2/crates/knot-bench/src/fixtures.rs @@ -444,17 +444,17 @@ impl BuiltTracker { let store = CobStore::new(&git); let owner = synthetic_account(0); let cx = owner_context(&owner, &self.repo); - let stride = ::SNAPSHOT_STRIDE.get() as u32; - (1..=stride).fold(issue_body(&self.repo, &owner, 0).cid(), |from, step| { - let produces = issue_body(&self.repo, &owner, step).cid(); - let change = knot_cobs::IssueChange::Edit { + let stride = ::SNAPSHOT_STRIDE.get() as u32; + (1..=stride).fold(ticket_body(&owner, 0).cid(), |from, step| { + let produces = ticket_body(&owner, step).cid(); + let change = knot_cobs::TicketChange::Edit { editing: knot_cobs::Editing { author: owner.clone(), from, produces, edited_at: UnixSeconds::new(GENESIS_SECONDS + i64::from(step)), }, - body: knot_cobs::Issue, + body: ticket(knot_record::ticket::TicketState::Open), }; let approved = knot_cob::Approved::signed_by( &cx, @@ -465,7 +465,7 @@ impl BuiltTracker { ) .expect("the owner signs its own change"); store - .append_approved::( + .append_approved::( &CobHome::from(&self.repo), self.first, &approved, @@ -491,29 +491,31 @@ fn owner_context<'a>(owner: &'a AccountDid, repo: &'a RepoDid) -> knot_cob::Writ ) } -fn issue_body(repo: &RepoDid, owner: &AccountDid, revision: u32) -> knot_types::RecordBody { +fn ticket_body(owner: &AccountDid, revision: u32) -> knot_types::RecordBody { use knot_record::Prose; - use knot_record::issue::{IssueRecord, IssueTitle}; + use knot_record::ticket::{TicketRecord, TicketState, TicketTitle}; let prose = Prose::new( - IssueTitle::new(format!("revision {revision}")).expect("a numbered title"), + TicketTitle::new(format!("revision {revision}")).expect("a numbered title"), None, None, None, ) .expect("prose without lists"); - IssueRecord::new( - repo.clone(), + TicketRecord::new( prose, + TicketState::Open, + None, + None, None, UnixSeconds::new(GENESIS_SECONDS), owner.clone(), ) - .expect("issue record") + .expect("ticket record") .body() - .expect("issue record is dag-cbor") + .expect("ticket record is dag-cbor") } -fn issue_address(collection: RecordCollection, index: u32) -> knot_types::RecordAddress { +fn ticket_address(collection: RecordCollection, index: u32) -> knot_types::RecordAddress { knot_types::RecordAddress::new( collection, knot_types::RecordRkey::minted(knot_types::Tid::from_time( @@ -523,6 +525,13 @@ fn issue_address(collection: RecordCollection, index: u32) -> knot_types::Record ) } +fn ticket(state: knot_record::ticket::TicketState) -> knot_cobs::Ticket { + knot_cobs::Ticket { + state, + tangled_labels: None, + } +} + fn open_social( store: &CobStore, cx: &knot_cob::WriteContext<'_>, @@ -557,8 +566,7 @@ pub fn build_issue_tracker(issues: IssueCount) -> BuiltTracker { let store = CobStore::new(&git); let owner = synthetic_account(0); let cx = owner_context(&owner, &repo); - let issue_body = issue_body(&repo, &owner, 0); - let closed = knot_record::issue::IssueState::Closed; + let ticket_body = ticket_body(&owner, 0); let opened: Vec = (0..issues.get()) .map(|index| { let number = knot_types::SubjectNumber::new(index + 1).expect("nonzero"); @@ -574,38 +582,15 @@ pub fn build_issue_tracker(issues: IssueCount) -> BuiltTracker { produces, } }; - let address = issue_address(knot_record::issue::issue_collection(), index); - let object = open_social( + let address = ticket_address(knot_record::ticket::ticket_collection(), index); + open_social( &store, &cx, &signer, - creation(address.clone(), None, issue_body.cid()), - knot_cobs::Issue, + creation(address.clone(), None, ticket_body.cid()), + ticket(knot_record::ticket::TicketState::Closed), knot_cob::Record::Bare, - ); - let state = knot_record::issue::IssueStateRecord::new( - repo.clone(), - address.clone(), - closed, - UnixSeconds::new(GENESIS_SECONDS), - owner.clone(), ) - .expect("state record") - .body() - .expect("state record is dag-cbor"); - open_social( - &store, - &cx, - &signer, - creation( - issue_address(knot_record::issue::issue_state_collection(), index), - Some(address), - state.cid(), - ), - knot_cobs::Transition { state: closed }, - knot_cob::Record::version(state), - ); - object }) .collect(); diff --git a/knot2/crates/knot-cobs/src/comment.rs b/knot2/crates/knot-cobs/src/comment.rs index 7793590a8..2ed29e16d 100644 --- a/knot2/crates/knot-cobs/src/comment.rs +++ b/knot2/crates/knot-cobs/src/comment.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use knot_record::comment::{CommentRef, PullRoundIdx}; +use knot_record::comment::CommentRef; use knot_types::RecordCid; use crate::Opening; @@ -9,8 +9,6 @@ use crate::Opening; pub struct Comment { pub subject: RecordCid, pub reply: Option, - #[serde(default)] - pub round: Option, } crate::social_cob! { @@ -28,7 +26,7 @@ mod tests { use crate::social::seeded; use knot_cob::ChangePayload; use knot_record::comment::comment_collection; - use knot_record::issue::issue_collection; + use knot_record::ticket::ticket_collection; use knot_types::{RecordAddress, RecordRkey, dag_cbor_cid}; fn open(body: Comment) -> SocialChange { @@ -39,7 +37,7 @@ mod tests { RecordRkey::new("3lubrptx57d22").unwrap(), ), Some(RecordAddress::new( - issue_collection(), + ticket_collection(), RecordRkey::new("3lubrptx57d33").unwrap(), )), ), @@ -59,7 +57,6 @@ mod tests { let change = open(Comment { subject: RecordCid::new(dag_cbor_cid(b"subject")).unwrap(), reply: Some(reply), - round: None, }); assert_eq!( CommentChange::decode(&change.encode().unwrap()).unwrap(), diff --git a/knot2/crates/knot-cobs/src/issue.rs b/knot2/crates/knot-cobs/src/issue.rs deleted file mode 100644 index 6d32a2566..000000000 --- a/knot2/crates/knot-cobs/src/issue.rs +++ /dev/null @@ -1,133 +0,0 @@ -use knot_record::issue::{ISSUE_COLLECTION, ISSUE_STATE_COLLECTION, IssueState}; -use serde::{Deserialize, Serialize}; - -use crate::Opening; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct Issue; - -crate::social_cob! { - body = Issue, - change = IssueChange, - cob = IssueCob, - collection = ISSUE_COLLECTION, - opening = Opening::Contribution, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct Transition { - pub state: IssueState, -} - -crate::social_cob! { - body = Transition, - change = IssueStateChange, - cob = IssueStateCob, - collection = ISSUE_STATE_COLLECTION, - opening = Opening::Moderation, -} - -pub fn social_collections() -> [knot_types::RecordCollection; 8] { - [ - knot_record::issue::issue_collection(), - knot_record::issue::issue_state_collection(), - knot_record::comment::comment_collection(), - knot_record::reaction::reaction_collection(), - knot_record::label::label_def_collection(), - knot_record::label::label_op_collection(), - knot_record::pull::pull_collection(), - knot_record::pull::pull_status_collection(), - ] -} - -pub fn social_state( - store: &knot_cob::CobStore, - type_name: &knot_types::TypeName, - object: knot_cob::CobId, -) -> Result, knot_cob::CobError> { - match type_name.as_str() { - ISSUE_COLLECTION => store.materialize::(object).map(Some), - ISSUE_STATE_COLLECTION => store.materialize::(object).map(Some), - knot_record::comment::COMMENT_COLLECTION => { - store.materialize::(object).map(Some) - } - knot_record::reaction::REACTION_COLLECTION => { - store.materialize::(object).map(Some) - } - knot_record::label::LABEL_DEF_COLLECTION => { - store.materialize::(object).map(Some) - } - knot_record::label::LABEL_OP_COLLECTION => { - store.materialize::(object).map(Some) - } - knot_record::pull::PULL_COLLECTION => store.materialize::(object).map(Some), - knot_record::pull::PULL_STATUS_COLLECTION => { - store.materialize::(object).map(Some) - } - _ => Ok(None), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Creation, SocialChange}; - use knot_cob::{ChangePayload, Emits}; - use knot_record::issue::{issue_collection, issue_state_collection}; - use knot_types::{ - AccountDid, RecordAddress, RecordBody, RecordCollection, RecordRkey, SubjectNumber, - UnixSeconds, - }; - - fn creation(collection: RecordCollection) -> Creation { - Creation { - address: RecordAddress::new(collection, RecordRkey::new("3lubrptx57d22").unwrap()), - author: AccountDid::new("did:plc:nel").unwrap(), - number: SubjectNumber::FIRST, - parent: None, - created_at: UnixSeconds::new(1), - produces: RecordBody::new(vec![0x64, b'k', b'e', b'l', b'p']) - .unwrap() - .cid(), - } - } - - fn open_changes() -> (SocialChange, SocialChange) { - ( - SocialChange::Open { - creation: creation(issue_collection()), - body: Issue, - }, - SocialChange::Open { - creation: creation(issue_state_collection()), - body: Transition { - state: IssueState::Closed, - }, - }, - ) - } - - #[test] - fn issue_and_transition_versions_decode_faithfully() { - let (issue, transition) = open_changes(); - assert_eq!( - issue.emits(), - Emits::Version(creation(issue_collection()).produces) - ); - assert_eq!( - transition.emits(), - Emits::Version(creation(issue_state_collection()).produces) - ); - let (issue_bytes, transition_bytes) = - (issue.encode().unwrap(), transition.encode().unwrap()); - assert_eq!(IssueChange::decode(&issue_bytes).unwrap(), issue); - assert_eq!( - IssueStateChange::decode(&transition_bytes).unwrap(), - transition - ); - assert!( - IssueStateChange::decode(&issue_bytes).is_err(), - "An issue's null body mustn't be a transition" - ); - } -} diff --git a/knot2/crates/knot-cobs/src/lib.rs b/knot2/crates/knot-cobs/src/lib.rs index b8bf8d5eb..359df237f 100644 --- a/knot2/crates/knot-cobs/src/lib.rs +++ b/knot2/crates/knot-cobs/src/lib.rs @@ -3,29 +3,22 @@ mod collaborators; mod comment; mod grant; mod import; -mod issue; mod label; mod members; mod pull; mod reaction; mod registry; mod social; +mod ticket; pub use blocklist::{Blocklist, BlocklistChange, BlocklistCob}; -pub use label::{ - DefRef, LabelDef, LabelDefChange, LabelDefCob, LabelOp, LabelOpChange, LabelOpCob, LabelVerb, - OpOperand, OperandValue, -}; +pub use label::{LabelDef, LabelDefChange, LabelDefCob}; pub use collaborators::{Collaborators, CollaboratorsChange, CollaboratorsCob}; pub use comment::{Comment, CommentChange, CommentCob}; pub use grant::{Accept, Effect, Entry, Grant, GrantChange, Invite, Removal, Roll}; pub use import::{ImportError, verify_cob_ref}; -pub use issue::{ - Issue, IssueChange, IssueCob, IssueStateChange, IssueStateCob, Transition, social_collections, - social_state, -}; pub use members::{Members, MembersChange, MembersCob}; -pub use pull::{Pull, PullChange, PullCob, PullStatusChange, PullStatusCob, StatusTransition}; +pub use pull::{Patch, PatchChange, PatchCob}; pub use reaction::{Reaction, ReactionChange, ReactionCob, ReactionKind}; pub use registry::{ Policy, Registration, RegistrationField, Registry, RegistryChange, RegistryError, Rename, @@ -36,6 +29,7 @@ pub use social::{ AnyBody, AnySocialChange, Creation, Editing, Emission, Erasure, Opening, Social, SocialBody, SocialChange, SocialCob, SocialObject, SocialState, decode_social, }; +pub use ticket::{Ticket, TicketChange, TicketCob, social_collections, social_state}; #[doc(hidden)] pub mod fuzz { @@ -52,9 +46,7 @@ pub mod fuzz { let _ = crate::CommentChange::decode(data); let _ = crate::ReactionChange::decode(data); let _ = crate::LabelDefChange::decode(data); - let _ = crate::PullChange::decode(data); - let _ = crate::PullStatusChange::decode(data); - let _ = crate::LabelOpChange::decode(data); + let _ = crate::PatchChange::decode(data); } pub fn ref_parse(data: &[u8]) { diff --git a/knot2/crates/knot-cobs/src/pull.rs b/knot2/crates/knot-cobs/src/pull.rs index d5b00dcfa..0619f9582 100644 --- a/knot2/crates/knot-cobs/src/pull.rs +++ b/knot2/crates/knot-cobs/src/pull.rs @@ -1,36 +1,16 @@ -use knot_record::pull::{ - PULL_COLLECTION, PULL_STATUS_COLLECTION, PullSource, PullStatus, PullVersions, -}; -use knot_types::BranchName; use serde::{Deserialize, Serialize}; use crate::Opening; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Pull { - pub target: BranchName, - pub source: Option, - pub versions: Vec, -} -crate::social_cob! { - body = Pull, - change = PullChange, - cob = PullCob, - collection = PULL_COLLECTION, - opening = Opening::Contribution, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StatusTransition { - pub status: PullStatus, -} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Patch; crate::social_cob! { - body = StatusTransition, - change = PullStatusChange, - cob = PullStatusCob, - collection = PULL_STATUS_COLLECTION, - opening = Opening::Moderation, + body = Patch, + change = PatchChange, + cob = PatchCob, + collection = knot_record::pull::PATCH_COLLECTION, + opening = Opening::Contribution, } #[cfg(test)] @@ -39,62 +19,29 @@ mod tests { use crate::SocialChange; use crate::social::seeded; use knot_cob::ChangePayload; - use knot_record::pull::{MergeMove, pull_collection, pull_status_collection}; - use knot_types::{Oid, RecordAddress, RecordRkey, RefName}; - - fn pair() -> PullVersions { - PullVersions { - base: Oid::from_hex("5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e").unwrap(), - head: Oid::from_hex("7c3e9f1a2b3c4d5e6f708192a3b4c5d6e7f80912").unwrap(), - } - } - - fn pull_address(rkey: &str) -> RecordAddress { - RecordAddress::new(pull_collection(), RecordRkey::new(rkey).unwrap()) - } + use knot_record::pull::patch_collection; + use knot_record::ticket::ticket_collection; + use knot_types::{RecordAddress, RecordRkey}; #[test] - fn pull_and_status_changes_round_trip() { - let pull = SocialChange::Open { - creation: seeded(pull_address("3lubrptx57d22"), None), - body: Pull { - target: BranchName::new("main").unwrap(), - source: Some(PullSource { - branch: BranchName::new("kelp-forest").unwrap(), - repo: None, - }), - versions: vec![pair()], - }, + fn patch_change_round_trips() { + let change = SocialChange::Open { + creation: seeded( + RecordAddress::new( + patch_collection(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ), + Some(RecordAddress::new( + ticket_collection(), + RecordRkey::new("3lubrptx57d33").unwrap(), + )), + ), + body: Patch, }; assert_eq!( - PullChange::decode(&pull.encode().unwrap()).unwrap(), - pull, - "a pull's target, source and versions round trip" + PatchChange::decode(&change.encode().unwrap()).unwrap(), + change, + "a patch change round trips through its own encoding" ); - for status in [ - PullStatus::Open, - PullStatus::Closed, - PullStatus::Merged(MergeMove { - branch: RefName::new("refs/heads/main").unwrap(), - old: pair().base, - new: pair().head, - }), - ] { - let transition = SocialChange::Open { - creation: seeded( - RecordAddress::new( - pull_status_collection(), - RecordRkey::new("3lubrptx57d33").unwrap(), - ), - Some(pull_address("3lubrptx57d22")), - ), - body: StatusTransition { status }, - }; - assert_eq!( - PullStatusChange::decode(&transition.encode().unwrap()).unwrap(), - transition, - "each status token round trips, with its move when merged" - ); - } } } diff --git a/knot2/crates/knot-cobs/src/reaction.rs b/knot2/crates/knot-cobs/src/reaction.rs index 685c4ed7a..b0f1026cc 100644 --- a/knot2/crates/knot-cobs/src/reaction.rs +++ b/knot2/crates/knot-cobs/src/reaction.rs @@ -1,11 +1,13 @@ use serde::{Deserialize, Serialize}; pub use knot_record::reaction::ReactionKind; +use knot_types::RecordCid; use crate::Opening; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Reaction { + pub subject: RecordCid, pub kind: ReactionKind, } @@ -22,8 +24,8 @@ mod tests { use crate::social::seeded; use crate::{Erasure, SocialChange}; use knot_cob::ChangePayload; - use knot_record::issue::issue_collection; use knot_record::reaction::reaction_collection; + use knot_record::ticket::ticket_collection; use knot_types::{AccountDid, RecordAddress, RecordCid, RecordRkey, UnixSeconds, dag_cbor_cid}; fn open(kind: ReactionKind) -> SocialChange { @@ -34,11 +36,14 @@ mod tests { RecordRkey::new("3lubrptx57d22").unwrap(), ), Some(RecordAddress::new( - issue_collection(), + ticket_collection(), RecordRkey::new("3lubrptx57d33").unwrap(), )), ), - body: Reaction { kind }, + body: Reaction { + subject: RecordCid::new(dag_cbor_cid(b"subject")).unwrap(), + kind, + }, } } diff --git a/knot2/crates/knot-cobs/src/ticket.rs b/knot2/crates/knot-cobs/src/ticket.rs new file mode 100644 index 000000000..b0099046a --- /dev/null +++ b/knot2/crates/knot-cobs/src/ticket.rs @@ -0,0 +1,79 @@ +use knot_record::ticket::{TICKET_COLLECTION, TicketState}; +use knot_types::LabelKey; +use serde::{Deserialize, Serialize}; + +use crate::Opening; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ticket { + pub state: TicketState, + #[serde(default)] + pub tangled_labels: Option>, +} + +crate::social_cob! { + body = Ticket, + change = TicketChange, + cob = TicketCob, + collection = TICKET_COLLECTION, + opening = Opening::Contribution, +} + +macro_rules! social_kinds { + ($($body:ty),+ $(,)?) => { + const SOCIAL_KINDS: usize = [$(stringify!($body)),+].len(); + + pub fn social_collections() -> [knot_types::RecordCollection; SOCIAL_KINDS] { + [$( + knot_types::RecordCollection::new(<$body as $crate::SocialBody>::COLLECTION) + .expect("a social collection is a valid nsid"), + )+] + } + + pub fn social_state( + store: &knot_cob::CobStore, + type_name: &knot_types::TypeName, + object: knot_cob::CobId, + ) -> Result, knot_cob::CobError> { + match type_name.as_str() { + $(<$body as $crate::SocialBody>::COLLECTION => + store.materialize::<$crate::SocialCob<$body>>(object).map(Some),)+ + _ => Ok(None), + } + } + }; +} + +social_kinds!( + crate::Ticket, + crate::Comment, + crate::Reaction, + crate::LabelDef, + crate::Patch +); + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SocialChange, social::seeded}; + use knot_cob::ChangePayload; + use knot_record::ticket::ticket_collection; + use knot_types::{RecordAddress, RecordRkey}; + + #[test] + fn ticket_change_round_trips_with_its_state() { + let rkey = RecordRkey::new("3lubrptx57d22").unwrap(); + let change = SocialChange::Open { + creation: seeded(RecordAddress::new(ticket_collection(), rkey), None), + body: Ticket { + state: TicketState::Open, + tangled_labels: None, + }, + }; + assert_eq!( + TicketChange::decode(&change.encode().unwrap()).unwrap(), + change, + "the state the write path signed is the state the fold reads back" + ); + } +} diff --git a/knot2/crates/knot-index/src/lib.rs b/knot2/crates/knot-index/src/lib.rs index 2ea8212ad..c75f96744 100644 --- a/knot2/crates/knot-index/src/lib.rs +++ b/knot2/crates/knot-index/src/lib.rs @@ -639,7 +639,7 @@ impl Index { &self, repo: &RepoDid, subject: &RecordAddress, - ) -> Resolved>> { + ) -> Resolved> { self.social.labels_of(&self.interner, repo, subject) } diff --git a/knot2/crates/knot-index/src/social.rs b/knot2/crates/knot-index/src/social.rs index 21986e592..8a61dec64 100644 --- a/knot2/crates/knot-index/src/social.rs +++ b/knot2/crates/knot-index/src/social.rs @@ -77,7 +77,7 @@ pub struct SocialEntry { pub state: SocialEntryState, } -type Labelings = Box<[knot_cobs::OpOperand]>; +type Labelings = Box<[knot_types::LabelKey]>; #[derive(Debug, Clone, PartialEq, Eq)] struct Objected { @@ -229,47 +229,13 @@ impl RepoSocial { }); } - fn labels_of( - &self, - interner: &Interner, - subject: &Slot, - ) -> BTreeMap> { - let mut labels: BTreeMap> = BTreeMap::new(); - interner - .collection(&knot_cobs::LabelOpCob::collection()) - .and_then(|ops| self.children.get(subject)?.get(&ops)) - .into_iter() - .flatten() - .map(|ordered| ordered.object()) - .filter(|object| self.is_serving(object)) - .filter_map(|object| self.objects.get(&object)?.labeled.as_ref()) - .flatten() - .for_each(|operand| { - let set = labels.entry(operand.key.clone()).or_default(); - match (operand.verb, operand.multiple) { - (knot_cobs::LabelVerb::Add, true) if !set.contains(&operand.value) => { - set.push(operand.value.clone()); - } - (knot_cobs::LabelVerb::Add, true) => {} - (knot_cobs::LabelVerb::Add, false) => { - set.clear(); - set.push(operand.value.clone()); - } - (knot_cobs::LabelVerb::Delete, true) => { - set.retain(|standing| *standing != operand.value); - } - (knot_cobs::LabelVerb::Delete, false) - if set - .first() - .is_some_and(|standing| *standing == operand.value) => - { - set.clear(); - } - (knot_cobs::LabelVerb::Delete, false) => {} - } - }); - labels.retain(|_, values| !values.is_empty()); - labels + fn labels_of(&self, subject: &Slot) -> Vec { + self.addressed + .get(subject) + .and_then(|object| self.objects.get(object)) + .and_then(|objected| objected.labeled.as_ref()) + .map(|labels| labels.to_vec()) + .unwrap_or_default() } fn unlist(&mut self, objected: &Objected, ordered: Ordered) { @@ -668,11 +634,11 @@ impl SocialProjection { interner: &Interner, repo: &RepoDid, subject: &RecordAddress, - ) -> Resolved>> { + ) -> Resolved> { let slot = Slot::known(interner, subject); self.read(interner, repo, |social| { slot.filter(|slot| social.live_at(slot)) - .map_or_else(BTreeMap::new, |slot| social.labels_of(interner, &slot)) + .map_or_else(Vec::new, |slot| social.labels_of(&slot)) }) } @@ -828,7 +794,7 @@ impl SocialProjection { } } -static PROJECTED: LazyLock<[RecordCollection; 8]> = LazyLock::new(knot_cobs::social_collections); +static PROJECTED: LazyLock<[RecordCollection; 5]> = LazyLock::new(knot_cobs::social_collections); fn is_social(type_name: &TypeName) -> bool { PROJECTED @@ -836,22 +802,23 @@ fn is_social(type_name: &TypeName) -> bool { .any(|collection| collection.as_str() == type_name.as_str()) } -fn with_opened_root( +fn with_latest_change( store: &CobStore, type_name: &TypeName, object: CobId, - root: Option<&Change>, + known: Option<&Change>, state: &knot_cobs::SocialState, place: impl FnOnce(&Change) -> Result, ) -> Result, IndexError> { if state.object().is_none() { return Ok(None); } - let fetched = root + let fetched = known .is_none() .then(|| store.changes_of(type_name, object, HistoryModel::Convergent, None)) .transpose()?; - root.or_else(|| fetched.as_ref().and_then(|fetched| fetched.changes.first())) + known + .or_else(|| fetched.as_ref().and_then(|fetched| fetched.changes.last())) .map(place) .transpose() } @@ -860,27 +827,29 @@ fn label_placement( store: &CobStore, type_name: &TypeName, object: CobId, - root: Option<&Change>, + known: Option<&Change>, state: &knot_cobs::SocialState, ) -> Result, IndexError> { - if type_name.as_str() != knot_cobs::LabelOpCob::collection().as_str() { + if type_name.as_str() != knot_cobs::TicketCob::collection().as_str() { return Ok(None); } - with_opened_root(store, type_name, object, root, state, |root| { - let decoded = knot_cobs::LabelOpChange::decode(root.payload()).map_err(|error| { + with_latest_change(store, type_name, object, known, state, |latest| { + let decoded = knot_cobs::TicketChange::decode(latest.payload()).map_err(|error| { IndexError::Decode { - change: root.id, + change: latest.id, type_name: type_name.clone(), reason: error.to_string(), } })?; Ok(match decoded { - knot_cobs::SocialChange::Open { body, .. } => body.operands.into(), - knot_cobs::SocialChange::Edit { .. } | knot_cobs::SocialChange::Erase { .. } => { - Box::default() + knot_cobs::SocialChange::Open { body, .. } + | knot_cobs::SocialChange::Edit { body, .. } => { + body.tangled_labels.map(Vec::into_boxed_slice) } + knot_cobs::SocialChange::Erase { .. } => None, }) }) + .map(Option::flatten) } fn project_object( @@ -973,10 +942,10 @@ fn rebuild_whole( store, type_name, object, - delta.changes.first(), + delta.changes.last(), &state, )?, - label_placement(store, type_name, object, delta.changes.first(), &state)?, + label_placement(store, type_name, object, delta.changes.last(), &state)?, ), )) } @@ -986,16 +955,16 @@ fn reaction_placement( store: &CobStore, type_name: &TypeName, object: CobId, - root: Option<&Change>, + latest: Option<&Change>, state: &knot_cobs::SocialState, ) -> Result, IndexError> { if type_name.as_str() != knot_cobs::ReactionCob::collection().as_str() { return Ok(None); } - with_opened_root(store, type_name, object, root, state, |root| { - let decoded = knot_cobs::ReactionChange::decode(root.payload()).map_err(|error| { + with_latest_change(store, type_name, object, latest, state, |latest| { + let decoded = knot_cobs::ReactionChange::decode(latest.payload()).map_err(|error| { IndexError::Decode { - change: root.id, + change: latest.id, type_name: type_name.clone(), reason: error.to_string(), } diff --git a/knot2/crates/knot-lexicons/Cargo.toml b/knot2/crates/knot-lexicons/Cargo.toml index a20023c44..af9105d83 100644 --- a/knot2/crates/knot-lexicons/Cargo.toml +++ b/knot2/crates/knot-lexicons/Cargo.toml @@ -25,6 +25,10 @@ walkdir = { workspace = true } [features] default = ["sh_tangled"] com_atproto = [] +com_bad_example = [] org_tangled = [] sh_tangled = ["com_atproto"] streaming = [] + +[lints.clippy] +unnecessary_lazy_evaluations = "allow" diff --git a/knot2/crates/knot-record/src/chain.rs b/knot2/crates/knot-record/src/chain.rs index c9c4a5cca..ceffd8db1 100644 --- a/knot2/crates/knot-record/src/chain.rs +++ b/knot2/crates/knot-record/src/chain.rs @@ -1737,32 +1737,32 @@ mod tests { ); } - fn issue_collection() -> RecordCollection { - RecordCollection::new("sh.tangled.repo.issue").unwrap() + fn chain_ticket_collection() -> RecordCollection { + RecordCollection::new("org.tangled.track.ticket").unwrap() } - fn issue_address(n: u64) -> RecordAddress { + fn ticket_address(n: u64) -> RecordAddress { RecordAddress::new( - knot_types::RecordCollection::new("sh.tangled.repo.issue").unwrap(), + knot_types::RecordCollection::new("org.tangled.track.ticket").unwrap(), knot_types::RecordRkey::minted(Tid::from_time(1_700_000_000_000_000 + n, 0)), ) } - fn issue_body(revision: u64) -> RecordBody { - RecordBody::new(issue_record(revision)).expect("Issue record is dag-cbor") + fn ticket_body(revision: u64) -> RecordBody { + RecordBody::new(ticket_record_bytes(revision)).expect("Issue record is dag-cbor") } - fn issue_record(revision: u64) -> Vec { + fn ticket_record_bytes(revision: u64) -> Vec { serde_ipld_dagcbor::to_vec(&BTreeMap::from([ - ("$type", "sh.tangled.repo.issue".to_owned()), + ("$type", "org.tangled.track.ticket".to_owned()), ("title", format!("kelp {revision}")), ])) .unwrap() } - fn issues(count: u64) -> BTreeMap { + fn ticket_records(count: u64) -> BTreeMap { (0..count) - .map(|n| (issue_address(n).mst_key(), issue_body(n))) + .map(|n| (ticket_address(n).mst_key(), ticket_body(n))) .collect() } @@ -1794,13 +1794,13 @@ mod tests { #[tokio::test] async fn planned_chunk_keeps_leaf_in_git_and_frame_lists_it() { let (_dir, repo) = repo(); - enable(&repo, &issues(1), &key()).await.unwrap(); - let body = issue_body(7); + enable(&repo, &ticket_records(1), &key()).await.unwrap(); + let body = ticket_body(7); let chunks = apply( &repo, &repo_did(), &[( - issue_address(7), + ticket_address(7), RecordOp::Create { record: body.clone(), }, @@ -1840,7 +1840,7 @@ mod tests { "the git block tree must answer the leaf directly" ); assert_eq!( - leaves_of(&repo).await.get(&issue_address(7).mst_key()), + leaves_of(&repo).await.get(&ticket_address(7).mst_key()), Some(&leaf), "the MST must point at the leaf whichever store answers it" ); @@ -1849,11 +1849,11 @@ mod tests { #[tokio::test] async fn chunks_split_at_blocks_byte_limit() { let (_dir, repo) = repo(); - enable(&repo, &issues(1), &key()).await.unwrap(); + enable(&repo, &ticket_records(1), &key()).await.unwrap(); let heft = |n: u64| { RecordBody::new( serde_ipld_dagcbor::to_vec(&BTreeMap::from([ - ("$type", "sh.tangled.repo.issue".to_owned()), + ("$type", "org.tangled.track.ticket".to_owned()), ("title", format!("kelp {n}")), ("body", "k".repeat(900_000)), ])) @@ -1862,7 +1862,12 @@ mod tests { .unwrap() }; let ops: Vec<(RecordAddress, RecordOp)> = (0..3) - .map(|n| (issue_address(100 + n), RecordOp::Create { record: heft(n) })) + .map(|n| { + ( + ticket_address(100 + n), + RecordOp::Create { record: heft(n) }, + ) + }) .collect(); let chunks = apply( &repo, @@ -1893,20 +1898,20 @@ mod tests { #[tokio::test] async fn apply_stamps_the_current_cid_when_the_record_moved() { let (_dir, repo) = repo(); - let base = issues(2); + let base = ticket_records(2); enable(&repo, &base, &key()).await.unwrap(); let chain_tip = tip(&repo).unwrap().unwrap().git; - let live = cid_at(&base, &issue_address(0)); - let stale = - RecordCid::new(crate::cid_of(&issue_record(77))).expect("Fixture record is dag-cbor"); + let live = cid_at(&base, &ticket_address(0)); + let stale = RecordCid::new(crate::cid_of(&ticket_record_bytes(77))) + .expect("Fixture record is dag-cbor"); let moved = apply( &repo, &repo_did(), &[( - issue_address(0), + ticket_address(0), RecordOp::Update { - record: issue_body(1), + record: ticket_body(1), from: stale, }, )], @@ -1930,7 +1935,7 @@ mod tests { assert_eq!(claimed, &stale); assert_eq!( uri, - &issue_address(0).at_uri(&repo_did()), + &ticket_address(0).at_uri(&repo_did()), "and should include the at-uri to rebase onto" ); @@ -1938,9 +1943,9 @@ mod tests { &repo, &repo_did(), &[( - issue_address(0), + ticket_address(0), RecordOp::Create { - record: issue_body(3), + record: ticket_body(3), }, )], &key(), @@ -1956,7 +1961,7 @@ mod tests { let absent = apply( &repo, &repo_did(), - &[(issue_address(5), RecordOp::Delete { from: live })], + &[(ticket_address(5), RecordOp::Delete { from: live })], &key(), &mut Ticker::new(), UnixSeconds::new(2), @@ -1972,13 +1977,13 @@ mod tests { &repo_did(), &[ ( - issue_address(0), + ticket_address(0), RecordOp::Update { - record: issue_body(4), + record: ticket_body(4), from: live, }, ), - (issue_address(0), RecordOp::Delete { from: live }), + (ticket_address(0), RecordOp::Delete { from: live }), ], &key(), &mut Ticker::new(), @@ -2058,12 +2063,12 @@ mod tests { }), (Some(body), 2) => Some(RecordOp::Update { from: body.cid(), - record: issue_body(1_000 + n), + record: ticket_body(1_000 + n), }), (Some(body), 3) => Some(RecordOp::Delete { from: body.cid() }), (Some(_), _) | (None, 0) => None, (None, _) => Some(RecordOp::Create { - record: issue_body(n), + record: ticket_body(n), }), } } @@ -2074,7 +2079,7 @@ mod tests { .enumerate() .filter_map(|(index, action)| { let n = index as u64; - let key = issue_address(n).mst_key(); + let key = ticket_address(n).mst_key(); let existing = base.get(&key).cloned(); match op_of(existing.clone(), *action, n) { Some(op) => op.record().cloned().map(|record| (key, record)), @@ -2092,7 +2097,7 @@ mod tests { order .iter() .filter_map(|n| { - let address = issue_address(*n); + let address = ticket_address(*n); let existing = base.get(address.mst_key().as_str()).cloned(); op_of(existing, actions[*n as usize], *n).map(|op| (address, op)) }) @@ -2128,7 +2133,7 @@ mod tests { let synced = sync( &sync_repo, &repo_did(), - std::slice::from_ref(&issue_collection()), + std::slice::from_ref(&chain_ticket_collection()), &in_tree(desired), &key(), &mut Stamped(WRITTEN_AT), @@ -2175,7 +2180,7 @@ mod tests { order in Strategy::prop_shuffle(proptest::prelude::Just((0..=REACH).collect::>())) ) { let actions: Vec = drawn.iter().copied().chain([2]).collect(); - let base = issues(BASE_ISSUES); + let base = ticket_records(BASE_ISSUES); let ops = ops_in_order(&base, &actions, &order); let runtime = runtime(); let (synced, sync_root, applied, apply_root) = runtime.block_on( @@ -2202,7 +2207,7 @@ mod tests { #[tokio::test] async fn one_op_apply_covers_neighbors_whole_collection_sync_covers() { - let base = issues(BASE_ISSUES); + let base = ticket_records(BASE_ISSUES); let base = &base; futures::stream::iter([ (11u64, 2u8, "an update"), @@ -2246,7 +2251,7 @@ mod tests { }) .collect(); let order: Vec = (70..150).chain(240..320).chain(0..70).collect(); - let base = issues(OVER_LIMIT_ISSUES); + let base = ticket_records(OVER_LIMIT_ISSUES); let ops = ops_in_order(&base, &actions, &order); let (synced, sync_root, applied, apply_root) = synced_and_applied(&base, wanted(&base, &actions), &ops).await; @@ -2272,17 +2277,17 @@ mod tests { #[tokio::test] async fn update_to_unchanged_bytes_skips_commit() { let (_dir, repo) = repo(); - let base = issues(3); + let base = ticket_records(3); enable(&repo, &base, &key()).await.unwrap(); let before = tip(&repo).unwrap().unwrap(); let chunks = apply( &repo, &repo_did(), &[( - issue_address(1), + ticket_address(1), RecordOp::Update { - record: issue_body(1), - from: cid_at(&base, &issue_address(1)), + record: ticket_body(1), + from: cid_at(&base, &ticket_address(1)), }, )], &key(), diff --git a/knot2/crates/knot-record/src/comment.rs b/knot2/crates/knot-record/src/comment.rs index c0334c948..e7dd23bfe 100644 --- a/knot2/crates/knot-record/src/comment.rs +++ b/knot2/crates/knot-record/src/comment.rs @@ -3,34 +3,25 @@ use knot_types::{ }; use serde::{Deserialize, Serialize, Serializer}; -use crate::issue::MarkdownBody; +use crate::ticket::MarkdownBody; use crate::{CreatedAt, RecordError, encoded}; -pub const COMMENT_COLLECTION: &str = "sh.tangled.feed.comment"; +pub const COMMENT_COLLECTION: &str = "org.tangled.feed.comment"; -pub const MARKDOWN_COLLECTION: &str = "sh.tangled.markup.markdown"; +pub const MARKDOWN_COLLECTION: &str = "org.tangled.markup.markdown"; -pub fn comment_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(COMMENT_COLLECTION).expect("Comment collection is valid nsid") -} - -pub fn markup_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(MARKDOWN_COLLECTION).expect("Markup collection is valid nsid") -} +collection!( + comment_collection, + COMMENT_COLLECTION, + "Comment collection is valid nsid" +); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct PullRoundIdx(u32); +collection!( + markup_collection, + MARKDOWN_COLLECTION, + "Markup collection is valid nsid" +); -impl PullRoundIdx { - pub const fn new(index: u32) -> Self { - Self(index) - } - - pub const fn get(self) -> u32 { - self.0 - } -} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CommentRef { address: RecordAddress, @@ -54,26 +45,24 @@ impl CommentRef { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommentMarkup { text: MarkdownBody, - original: Option, attachments: Option>, } impl CommentMarkup { pub fn new( text: MarkdownBody, - original: Option, attachments: Option>, ) -> Self { - Self { - text, - original, - attachments, - } + Self { text, attachments } } pub fn attachments(&self) -> &[crate::attachment::Attachment] { self.attachments.as_deref().unwrap_or(&[]) } + + pub fn text(&self) -> &MarkdownBody { + &self.text + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,19 +71,16 @@ pub struct CommentRecord { subject: CommentRef, body: CommentMarkup, reply_to: Option, - pull_round_idx: Option, created_at: CreatedAt, editor: AccountDid, } impl CommentRecord { - #[allow(clippy::too_many_arguments)] pub fn new( repo: RepoDid, subject: CommentRef, body: CommentMarkup, reply_to: Option, - pull_round_idx: Option, created_at: UnixSeconds, editor: AccountDid, ) -> Result { @@ -103,7 +89,6 @@ impl CommentRecord { subject, body, reply_to, - pull_round_idx, created_at: CreatedAt::at(created_at)?, editor, }) @@ -115,12 +100,12 @@ impl CommentRecord { } #[derive(Serialize)] -struct CommentRefEncoding { +pub(crate) struct CommentRefEncoding { uri: AtUri, cid: RecordCid, } -fn ref_encoding(reference: &CommentRef, repo: &RepoDid) -> CommentRefEncoding { +pub(crate) fn ref_encoding(reference: &CommentRef, repo: &RepoDid) -> CommentRefEncoding { CommentRefEncoding { uri: reference.address.at_uri(repo), cid: reference.cid, @@ -128,28 +113,38 @@ fn ref_encoding(reference: &CommentRef, repo: &RepoDid) -> CommentRefEncoding { } #[derive(Serialize)] -struct MarkupEncoding<'a> { +pub(crate) struct MarkdownEncoding<'a> { #[serde(rename = "$type")] markup_type: &'static str, text: &'a MarkdownBody, - #[serde(skip_serializing_if = "Option::is_none")] - original: &'a Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "crate::attachment::embeds" + )] blobs: &'a Option>, } +pub(crate) fn markdown_encoding<'a>( + text: &'a MarkdownBody, + blobs: &'a Option>, +) -> MarkdownEncoding<'a> { + MarkdownEncoding { + markup_type: MARKDOWN_COLLECTION, + text, + blobs, + } +} + #[derive(Serialize)] struct CommentEncoding<'a> { #[serde(rename = "$type")] record_type: knot_types::RecordCollection, subject: CommentRefEncoding, - body: MarkupEncoding<'a>, + body: MarkdownEncoding<'a>, #[serde(rename = "createdAt")] created_at: &'a CreatedAt, #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] reply_to: Option, - #[serde(skip_serializing_if = "Option::is_none", rename = "pullRoundIdx")] - pull_round_idx: Option, #[serde(rename = "x-tngl-editor")] editor: &'a AccountDid, } @@ -159,18 +154,12 @@ impl Serialize for CommentRecord { CommentEncoding { record_type: comment_collection(), subject: ref_encoding(&self.subject, &self.repo), - body: MarkupEncoding { - markup_type: MARKDOWN_COLLECTION, - text: &self.body.text, - original: &self.body.original, - blobs: &self.body.attachments, - }, + body: markdown_encoding(&self.body.text, &self.body.attachments), created_at: &self.created_at, reply_to: self .reply_to .as_ref() .map(|reply| ref_encoding(reply, &self.repo)), - pull_round_idx: self.pull_round_idx, editor: &self.editor, } .serialize(serializer) @@ -183,45 +172,16 @@ mod tests { use crate::frozen; use knot_types::RecordRkey; - const COMMENT: &str = "a564626f6479a26474657874646b656c70652474797065781a73682e74616e676c65642e6d61726b75702e6d61726b646f776e6524747970657773682e74616e676c65642e666565642e636f6d6d656e74677375626a656374a263636964d82a58250001711220a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c5124263757269783661743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e7265706f2e69737375652f336c75627270747835376432326963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - - const ANSWERED: &str = "a664626f6479a36474657874646b656c70652474797065781a73682e74616e676c65642e6d61726b75702e6d61726b646f776e686f726967696e616c6c6b656c702c206265666f72656524747970657773682e74616e676c65642e666565642e636f6d6d656e74677265706c79546fa263636964d82a582500017112205782b18687e6cf8a482fc32d2db5b196d8821c458a0c069c6acf3953446e7bb563757269783861743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e666565642e636f6d6d656e742f336c7562727074783537643434677375626a656374a263636964d82a58250001711220a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c5124263757269783661743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e7265706f2e69737375652f336c75627270747835376432326963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - - fn editor() -> AccountDid { - AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() - } - - fn squid() -> RepoDid { - RepoDid::new("did:plc:squid").unwrap() - } + const COMMENT: &str = "a564626f6479a26474657874646b656c70652474797065781b6f72672e74616e676c65642e6d61726b75702e6d61726b646f776e65247479706578186f72672e74616e676c65642e666565642e636f6d6d656e74677375626a656374a263636964d82a58250001711220a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c5124263757269783961743a2f2f6469643a706c633a73717569642f6f72672e74616e676c65642e747261636b2e7469636b65742f336c75627270747835376432326963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - fn subject() -> CommentRef { - CommentRef::new( - RecordAddress::new( - crate::issue::issue_collection(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ), - knot_types::RecordCid::new(knot_types::dag_cbor_cid(b"subject")).unwrap(), - ) - } - - fn reply() -> CommentRef { - CommentRef::new( - RecordAddress::new( - comment_collection(), - RecordRkey::new("3lubrptx57d44").unwrap(), - ), - knot_types::RecordCid::new(knot_types::dag_cbor_cid(b"reply")).unwrap(), - ) - } + use crate::test_support::{editor, squid, subject}; #[test] fn comment_record_bytes_frozen() { let record = CommentRecord::new( squid(), subject(), - CommentMarkup::new(MarkdownBody::new("kelp").unwrap(), None, None), - None, + CommentMarkup::new(MarkdownBody::new("kelp").unwrap(), None), None, UnixSeconds::new(1), editor(), @@ -231,32 +191,60 @@ mod tests { assert_eq!( body.as_bytes(), frozen(COMMENT).as_slice(), - "furst comment ever written must produce exactly these bytes: keys ordered by \ - length, empty fields left out, whole seconds, and utc z" + "the first emitted comment must freeze these bytes: absent optionals omitted, seconds precision, utc z" ); } #[test] - fn comment_with_reply_and_original_freezes_its_own_bytes() { + fn comment_change_round_trips_with_its_refs() { + #[derive(serde::Deserialize)] + struct Decoded { + #[serde(rename = "$type")] + kind: String, + subject: DecodedRef, + body: DecodedBody, + #[serde(rename = "replyTo")] + reply_to: Option, + } + #[derive(serde::Deserialize)] + struct DecodedRef { + uri: String, + } + #[derive(serde::Deserialize)] + struct DecodedBody { + #[serde(rename = "$type")] + kind: String, + text: String, + } + + let reply = CommentRef::new( + RecordAddress::new( + comment_collection(), + RecordRkey::new("3lubrptx57d44").unwrap(), + ), + knot_types::RecordCid::new(knot_types::dag_cbor_cid(b"reply")).unwrap(), + ); let record = CommentRecord::new( squid(), subject(), - CommentMarkup::new( - MarkdownBody::new("kelp").unwrap(), - Some(MarkdownBody::new("kelp, before").unwrap()), - None, - ), - Some(reply()), - None, + CommentMarkup::new(MarkdownBody::new("kelp").unwrap(), None), + Some(reply), UnixSeconds::new(1), editor(), ) .unwrap(); - let body = record.body().unwrap(); + let decoded: Decoded = + serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); + assert_eq!(decoded.kind, COMMENT_COLLECTION); + assert_eq!(decoded.body.kind, MARKDOWN_COLLECTION); + assert_eq!(decoded.body.text, "kelp"); assert_eq!( - body.as_bytes(), - frozen(ANSWERED).as_slice(), - "reply and original should probably go under their own keys, byte order unchanged" + decoded.subject.uri, + "at://did:plc:squid/org.tangled.track.ticket/3lubrptx57d22" + ); + assert_eq!( + decoded.reply_to.unwrap().uri, + "at://did:plc:squid/org.tangled.feed.comment/3lubrptx57d44" ); } } diff --git a/knot2/crates/knot-record/src/git_refs.rs b/knot2/crates/knot-record/src/git_refs.rs index af7455fe8..eea5d7411 100644 --- a/knot2/crates/knot-record/src/git_refs.rs +++ b/knot2/crates/knot-record/src/git_refs.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use crate::chain::ChainError; use knot_git::Repo; -use knot_types::{MstKey, RecordBody, RecordBodyError, RecordCollection}; +use knot_types::{MstKey, RecordBody, RecordBodyError}; use serde::Serialize; pub const GIT_REFS_COLLECTION: &str = "org.tangled.git.refs"; @@ -12,9 +12,11 @@ pub const SELF_RKEY: &str = "self"; const HEADS_PREFIX: &str = "refs/heads/"; const DEFAULT_DEFAULT_BRANCH: &str = "main"; -pub fn git_refs_collection() -> RecordCollection { - RecordCollection::new(GIT_REFS_COLLECTION).expect("Git refs collection is valid nsid") -} +collection!( + git_refs_collection, + GIT_REFS_COLLECTION, + "Git refs collection is valid nsid" +); #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RefsRecord { diff --git a/knot2/crates/knot-record/src/issue.rs b/knot2/crates/knot-record/src/issue.rs deleted file mode 100644 index 65e0108f9..000000000 --- a/knot2/crates/knot-record/src/issue.rs +++ /dev/null @@ -1,355 +0,0 @@ -use std::fmt; - -use knot_types::{ - AccountDid, AtUri, RecordAddress, RecordBody, RecordBodyError, RepoDid, UnixSeconds, -}; -use serde::{Deserialize, Serialize, Serializer}; - -use crate::{CreatedAt, Prose, RecordError, encoded}; - -pub const ISSUE_COLLECTION: &str = "sh.tangled.repo.issue"; - -pub const ISSUE_STATE_COLLECTION: &str = "sh.tangled.repo.issue.state"; - -pub fn issue_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(ISSUE_COLLECTION).expect("Issue collection is valid nsid") -} - -pub fn issue_state_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(ISSUE_STATE_COLLECTION) - .expect("Issue state collection is valid nsid") -} - -#[derive(Debug, thiserror::Error)] -pub enum ProseError { - #[error("title is empty")] - EmptyTitle, - #[error("title of {0} bytes is over the {MAX_TITLE_BYTES} byte limit")] - TitleTooLong(usize), - #[error("body is empty")] - EmptyBody, - #[error("body of {0} bytes is over the {MAX_BODY_BYTES} byte limit")] - BodyTooLong(usize), - #[error("{0} mentions are over the {MAX_MENTIONS} limit")] - TooManyMentions(usize), - #[error("{0} references are over the {MAX_REFERENCES} limit")] - TooManyReferences(usize), -} - -pub const MAX_TITLE_BYTES: usize = 1_024; - -pub const MAX_BODY_BYTES: usize = 500_000; - -macro_rules! prose_text { - (text = $name:ident, limit = $limit:expr, empty = $empty:ident, long = $long:ident) => { - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] - #[serde(transparent)] - pub struct $name(String); - - impl $name { - pub fn new(text: impl Into) -> Result { - let text = text.into(); - match (text.trim().is_empty(), text.len()) { - (true, _) => Err(ProseError::$empty), - (false, len) if len > $limit => Err(ProseError::$long(len)), - (false, _) => Ok(Self(text)), - } - } - - pub fn as_str(&self) -> &str { - &self.0 - } - } - }; -} - -prose_text!( - text = IssueTitle, - limit = MAX_TITLE_BYTES, - empty = EmptyTitle, - long = TitleTooLong -); - -prose_text!( - text = MarkdownBody, - limit = MAX_BODY_BYTES, - empty = EmptyBody, - long = BodyTooLong -); - -pub const MAX_MENTIONS: usize = 64; - -pub const MAX_REFERENCES: usize = 64; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IssueRecord { - body: Option, - repo: RepoDid, - title: IssueTitle, - mentions: Option>, - created_at: CreatedAt, - references: Option>>, - attachments: Option>, - editor: AccountDid, -} - -impl IssueRecord { - pub fn new( - repo: RepoDid, - prose: Prose, - attachments: Option>, - created_at: UnixSeconds, - editor: AccountDid, - ) -> Result { - Ok(Self { - body: prose.body, - repo, - title: prose.title, - mentions: prose.mentions, - created_at: CreatedAt::at(created_at)?, - references: prose.references, - attachments, - editor, - }) - } - - pub fn body(&self) -> Result { - encoded(self) - } -} - -#[derive(Serialize)] -struct IssueEncoding<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - body: &'a Option, - repo: &'a RepoDid, - #[serde(rename = "$type")] - record_type: knot_types::RecordCollection, - title: &'a IssueTitle, - #[serde(skip_serializing_if = "Option::is_none")] - mentions: &'a Option>, - #[serde(rename = "createdAt")] - created_at: &'a CreatedAt, - #[serde(skip_serializing_if = "Option::is_none")] - references: &'a Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - blobs: &'a Option>, - #[serde(rename = "x-tngl-editor")] - editor: &'a AccountDid, -} - -impl Serialize for IssueRecord { - fn serialize(&self, serializer: S) -> Result { - IssueEncoding { - body: &self.body, - repo: &self.repo, - record_type: issue_collection(), - title: &self.title, - mentions: &self.mentions, - created_at: &self.created_at, - references: &self.references, - blobs: &self.attachments, - editor: &self.editor, - } - .serialize(serializer) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum IssueState { - #[serde(rename = "sh.tangled.repo.issue.state.open")] - Open, - #[serde(rename = "sh.tangled.repo.issue.state.closed")] - Closed, -} - -impl fmt::Display for IssueState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.pad(match self { - Self::Open => "sh.tangled.repo.issue.state.open", - Self::Closed => "sh.tangled.repo.issue.state.closed", - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IssueStateRecord { - repo: RepoDid, - issue: RecordAddress, - state: IssueState, - created_at: CreatedAt, - editor: AccountDid, -} - -impl IssueStateRecord { - pub fn new( - repo: RepoDid, - issue: RecordAddress, - state: IssueState, - created_at: UnixSeconds, - editor: AccountDid, - ) -> Result { - if issue.collection() != &issue_collection() { - return Err(RecordError::Malformed(format!( - "a state transition belongs to an issue in {}, not {}", - issue_collection(), - issue.collection() - ))); - } - Ok(Self { - repo, - issue, - state, - created_at: CreatedAt::at(created_at)?, - editor, - }) - } - - pub fn body(&self) -> Result { - encoded(self) - } -} - -#[derive(Serialize)] -struct IssueStateEncoding<'a> { - #[serde(rename = "$type")] - record_type: knot_types::RecordCollection, - issue: AtUri, - state: &'a IssueState, - #[serde(rename = "createdAt")] - created_at: &'a CreatedAt, - #[serde(rename = "x-tngl-editor")] - editor: &'a AccountDid, -} - -impl Serialize for IssueStateRecord { - fn serialize(&self, serializer: S) -> Result { - IssueStateEncoding { - record_type: issue_state_collection(), - issue: self.issue.at_uri(&self.repo), - state: &self.state, - created_at: &self.created_at, - editor: &self.editor, - } - .serialize(serializer) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::frozen; - use knot_types::RecordRkey; - - const ISSUE: &str = "a664626f647966666f72657374647265706f6d6469643a706c633a73717569646524747970657573682e74616e676c65642e7265706f2e6973737565657469746c65646b656c706963726561746564417474323032352d30382d31335431333a30313a33365a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - - fn editor() -> AccountDid { - AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() - } - - fn squid() -> RepoDid { - RepoDid::new("did:plc:squid").unwrap() - } - - fn prose(body: Option<&str>, mentions: Option>) -> Prose { - Prose::new( - IssueTitle::new("kelp").unwrap(), - body.map(|text| MarkdownBody::new(text).unwrap()), - mentions, - None, - ) - .unwrap() - } - - #[test] - fn issue_record_bytes_frozen() { - let record = IssueRecord::new( - squid(), - prose(Some("forest"), None), - None, - UnixSeconds::new(1_755_090_096), - editor(), - ) - .unwrap(); - let body = record.body().unwrap(); - assert_eq!( - body.as_bytes(), - frozen(ISSUE).as_slice(), - "the first emitted issue must freeze these bytes: length-first key order, absent optionals omitted, seconds precision, utc z" - ); - } - - #[test] - fn absent_body_is_omitted_but_sent_empty_list_is_kept() { - let sent = prose(None, Some(Vec::new())); - let record = IssueRecord::new( - squid(), - sent, - None, - UnixSeconds::new(1_755_090_096), - editor(), - ) - .unwrap(); - let bytes = record.body().unwrap(); - let value: serde_json::Value = serde_ipld_dagcbor::from_slice(bytes.as_bytes()).unwrap(); - assert_eq!(value.get("body"), None); - assert_eq!(value["mentions"], serde_json::json!([])); - assert_eq!(value.get("references"), None); - } - - #[test] - fn state_record_embeds_lexicon_token_and_parent_uri() { - let record = IssueStateRecord::new( - squid(), - RecordAddress::new( - issue_collection(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ), - IssueState::Closed, - UnixSeconds::new(1_755_090_096), - editor(), - ) - .unwrap(); - let value: serde_json::Value = - serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); - assert_eq!(value["$type"], ISSUE_STATE_COLLECTION); - assert_eq!(value["state"], "sh.tangled.repo.issue.state.closed"); - assert_eq!( - value["issue"], - "at://did:plc:squid/sh.tangled.repo.issue/3lubrptx57d22" - ); - assert_eq!(value["x-tngl-editor"], editor().as_str()); - } - - #[test] - fn state_transition_rejects_foreign_collection() { - let rejected = IssueStateRecord::new( - squid(), - RecordAddress::new( - knot_types::RecordCollection::new("sh.tangled.repo.gitRef").unwrap(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ), - IssueState::Open, - UnixSeconds::new(1_755_090_096), - editor(), - ); - assert!(rejected.is_err()); - } - - #[test] - fn issue_prose_must_say_something_and_not_too_much() { - assert!(matches!(IssueTitle::new(" "), Err(ProseError::EmptyTitle))); - assert!(matches!( - MarkdownBody::new("\n"), - Err(ProseError::EmptyBody) - )); - assert!(matches!( - IssueTitle::new("k".repeat(MAX_TITLE_BYTES + 1)), - Err(ProseError::TitleTooLong(_)) - )); - assert!(matches!( - MarkdownBody::new("k".repeat(MAX_BODY_BYTES + 1)), - Err(ProseError::BodyTooLong(_)) - )); - } -} diff --git a/knot2/crates/knot-record/src/lib.rs b/knot2/crates/knot-record/src/lib.rs index 0d594a2f2..0c3959828 100644 --- a/knot2/crates/knot-record/src/lib.rs +++ b/knot2/crates/knot-record/src/lib.rs @@ -1,17 +1,25 @@ +macro_rules! collection { + ($accessor:ident, $name:ident, $valid:literal) => { + pub fn $accessor() -> knot_types::RecordCollection { + knot_types::RecordCollection::new($name).expect($valid) + } + }; +} + pub mod attachment; pub mod blocks; pub mod chain; pub mod comment; pub mod git_ref; pub mod git_refs; -pub mod issue; pub mod label; pub mod mst; pub mod proof; pub mod pull; pub mod reaction; +pub mod ticket; -use crate::issue::{IssueTitle, MAX_MENTIONS, MAX_REFERENCES, MarkdownBody, ProseError}; +use crate::ticket::{MAX_MENTIONS, MAX_REFERENCES, MarkdownBody, ProseError, TicketTitle}; use bytes::Bytes; use cid::Cid; use knot_types::{AccountDid, AtUri, RecordBody, RecordBodyError, UnixSeconds}; @@ -44,7 +52,7 @@ impl CreatedAt { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Prose { - pub(crate) title: IssueTitle, + pub(crate) title: TicketTitle, pub(crate) body: Option, pub(crate) mentions: Option>, pub(crate) references: Option>>, @@ -52,7 +60,7 @@ pub struct Prose { impl Prose { pub fn new( - title: IssueTitle, + title: TicketTitle, body: Option, mentions: Option>, references: Option>>, @@ -90,6 +98,30 @@ pub(crate) fn frozen(hex: &str) -> Vec { .collect() } +#[cfg(test)] +pub(crate) mod test_support { + use crate::comment::CommentRef; + use knot_types::{AccountDid, RecordAddress, RecordCid, RecordRkey, RepoDid}; + + pub(crate) fn editor() -> AccountDid { + AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() + } + + pub(crate) fn squid() -> RepoDid { + RepoDid::new("did:plc:squid").unwrap() + } + + pub(crate) fn subject() -> CommentRef { + CommentRef::new( + RecordAddress::new( + crate::ticket::ticket_collection(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ), + RecordCid::new(knot_types::dag_cbor_cid(b"subject")).unwrap(), + ) + } +} + pub fn cid_of(block: &[u8]) -> Cid { knot_types::dag_cbor_cid(block) } diff --git a/knot2/crates/knot-record/src/pull.rs b/knot2/crates/knot-record/src/pull.rs index bba824ad1..0b48f5a36 100644 --- a/knot2/crates/knot-record/src/pull.rs +++ b/knot2/crates/knot-record/src/pull.rs @@ -1,277 +1,35 @@ -use knot_types::{ - AccountDid, AtUri, BranchName, Oid, RecordAddress, RecordBody, RecordBodyError, RefName, - RepoDid, UnixSeconds, -}; -use serde::{Deserialize, Serialize, Serializer}; +use knot_git::CommitRange; +use knot_types::{AccountDid, Oid, RecordBody, RecordBodyError, Tid, UnixSeconds}; +use serde::{Serialize, Serializer}; -use crate::issue::{IssueTitle, MarkdownBody}; -use crate::{CreatedAt, Prose, RecordError, encoded}; +use crate::{CreatedAt, RecordError, encoded}; -pub const PULL_COLLECTION: &str = "sh.tangled.repo.pull"; +pub const PATCH_COLLECTION: &str = "org.tangled.review.patch"; -pub const MAX_VERSIONS: usize = 256; - -pub const PULL_STATUS_COLLECTION: &str = "sh.tangled.repo.pull.status"; - -pub fn pull_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(PULL_COLLECTION).expect("Pull collection is valid nsid") -} - -pub fn pull_status_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(PULL_STATUS_COLLECTION) - .expect("Pull status collection is valid nsid") -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullVersions { - pub base: Oid, - pub head: Oid, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullSource { - pub branch: BranchName, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeMove { - pub branch: RefName, - pub old: Oid, - pub new: Oid, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum PullStatus { - #[serde(rename = "sh.tangled.repo.pull.status.open")] - Open, - #[serde(rename = "sh.tangled.repo.pull.status.closed")] - Closed, - #[serde(rename = "sh.tangled.repo.pull.status.merged")] - Merged(MergeMove), -} - -impl PullStatus { - pub const MERGED_TOKEN: &'static str = "sh.tangled.repo.pull.status.merged"; - - pub fn token(&self) -> &'static str { - match self { - Self::Open => "sh.tangled.repo.pull.status.open", - Self::Closed => "sh.tangled.repo.pull.status.closed", - Self::Merged(_) => Self::MERGED_TOKEN, - } - } - - pub fn open_or_closed(token: &str) -> Option { - match token { - "sh.tangled.repo.pull.status.open" => Some(Self::Open), - "sh.tangled.repo.pull.status.closed" => Some(Self::Closed), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullVersion { - base: Oid, - head: Oid, - #[serde(rename = "createdAt")] - created_at: CreatedAt, -} - -impl PullVersion { - pub fn stamped(versions: PullVersions, at: UnixSeconds) -> Result { - Ok(Self { - base: versions.base, - head: versions.head, - created_at: CreatedAt::at(at)?, - }) - } - - pub const fn pair(&self) -> PullVersions { - PullVersions { - base: self.base, - head: self.head, - } - } -} +collection!( + patch_collection, + PATCH_COLLECTION, + "Patch collection is valid nsid" +); #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PullRecord { - repo: RepoDid, - prose: Prose, - target: BranchName, - source: Option, - dependent_on: Option>, - versions: Vec, - attachments: Option>, +pub struct PatchRecord { + ticket: Tid, + range: CommitRange, created_at: CreatedAt, editor: AccountDid, } -impl PullRecord { - #[allow(clippy::too_many_arguments)] - pub fn new( - repo: RepoDid, - prose: Prose, - target: BranchName, - source: Option, - dependent_on: Option>, - versions: Vec, - attachments: Option>, - created_at: UnixSeconds, - editor: AccountDid, - ) -> Result { - match versions.len() { - 0 => Err(RecordError::Malformed( - "This pull record has no versions. Please send at least one.".to_string(), - )), - count if count > MAX_VERSIONS => Err(RecordError::Malformed(format!( - "A pull stops at {MAX_VERSIONS} versions, and this one has {count}." - ))), - _ => Ok(Self { - repo, - prose, - target, - source, - dependent_on, - versions, - attachments, - created_at: CreatedAt::at(created_at)?, - editor, - }), - } - } - pub fn body(&self) -> Result { - encoded(self) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct StandingPull { - target: BranchRead, - #[serde(default)] - source: Option, - versions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct BranchRead { - branch: BranchName, -} - -impl StandingPull { - pub fn decode(bytes: &[u8]) -> Result { - serde_ipld_dagcbor::from_slice(bytes) - .map_err(|error| RecordError::Malformed(error.to_string())) - } - pub fn target(&self) -> &BranchName { - &self.target.branch - } - - pub fn source(&self) -> Option<&PullSource> { - self.source.as_ref() - } - - pub fn versions(&self) -> &[PullVersion] { - &self.versions - } - - pub fn pairs(&self) -> Vec { - self.versions.iter().map(PullVersion::pair).collect() - } -} - -#[derive(Serialize)] -struct Round; - -const NO_ROUNDS: [Round; 0] = []; - -#[derive(Serialize)] -struct TargetEncoding<'a> { - repo: &'a RepoDid, - branch: &'a BranchName, -} - -#[derive(Serialize)] -struct PullEncoding<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - #[serde(rename = "$type")] - record_type: knot_types::RecordCollection, - title: IssueTitle, - rounds: &'a [Round], - #[serde(skip_serializing_if = "Option::is_none")] - source: Option<&'a PullSource>, - target: TargetEncoding<'a>, - #[serde(skip_serializing_if = "Option::is_none")] - mentions: Option>, - versions: &'a [PullVersion], - #[serde(skip_serializing_if = "Option::is_none")] - blobs: &'a Option>, - #[serde(rename = "createdAt")] - created_at: CreatedAt, - #[serde(skip_serializing_if = "Option::is_none")] - references: Option>>, - #[serde(rename = "dependentOn", skip_serializing_if = "Option::is_none")] - dependent_on: Option>, - #[serde(rename = "x-tngl-editor")] - editor: &'a AccountDid, -} - -impl Serialize for PullRecord { - fn serialize(&self, serializer: S) -> Result { - PullEncoding { - body: self.prose.body.clone(), - record_type: pull_collection(), - title: self.prose.title.clone(), - rounds: &NO_ROUNDS, - source: self.source.as_ref(), - target: TargetEncoding { - repo: &self.repo, - branch: &self.target, - }, - mentions: self.prose.mentions.clone(), - versions: &self.versions, - blobs: &self.attachments, - created_at: self.created_at.clone(), - references: self.prose.references.clone(), - dependent_on: self.dependent_on.clone(), - editor: &self.editor, - } - .serialize(serializer) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PullStatusRecord { - repo: RepoDid, - pull: RecordAddress, - status: PullStatus, - created_at: CreatedAt, - editor: AccountDid, -} - -impl PullStatusRecord { +impl PatchRecord { pub fn new( - repo: RepoDid, - pull: RecordAddress, - status: PullStatus, + ticket: Tid, + range: CommitRange, created_at: UnixSeconds, editor: AccountDid, ) -> Result { - if pull.collection() != &pull_collection() { - return Err(RecordError::Malformed(format!( - "A pull status belongs to a pull in {}, not {}.", - pull_collection(), - pull.collection() - ))); - } Ok(Self { - repo, - pull, - status, + ticket, + range, created_at: CreatedAt::at(created_at)?, editor, }) @@ -283,24 +41,35 @@ impl PullStatusRecord { } #[derive(Serialize)] -struct PullStatusEncoding<'a> { - pull: AtUri, +struct OidEncoding<'a> { + oid: &'a Oid, +} + +#[derive(Serialize)] +struct PatchEncoding<'a> { #[serde(rename = "$type")] record_type: knot_types::RecordCollection, - status: &'static str, + ticket: &'a Tid, + base: OidEncoding<'a>, + head: OidEncoding<'a>, #[serde(rename = "createdAt")] - created_at: CreatedAt, + created_at: &'a CreatedAt, #[serde(rename = "x-tngl-editor")] editor: &'a AccountDid, } -impl Serialize for PullStatusRecord { +impl Serialize for PatchRecord { fn serialize(&self, serializer: S) -> Result { - PullStatusEncoding { - pull: self.pull.at_uri(&self.repo), - record_type: pull_status_collection(), - status: self.status.token(), - created_at: self.created_at.clone(), + PatchEncoding { + record_type: patch_collection(), + ticket: &self.ticket, + base: OidEncoding { + oid: &self.range.base, + }, + head: OidEncoding { + oid: &self.range.head, + }, + created_at: &self.created_at, editor: &self.editor, } .serialize(serializer) @@ -310,188 +79,57 @@ impl Serialize for PullStatusRecord { #[cfg(test)] mod tests { use super::*; - use knot_types::RecordRkey; + use crate::frozen; + + const PATCH: &str = "a66462617365a1636f69647828616161616161616161616161616161616161616161616161616161616161616161616161616161616468656164a1636f696478286262626262626262626262626262626262626262626262626262626262626262626262626262626265247479706578186f72672e74616e676c65642e7265766965772e7061746368667469636b65746d336c75627270747835376432326963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; fn editor() -> AccountDid { AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() } - fn squid() -> RepoDid { - RepoDid::new("did:plc:squid").unwrap() - } - - fn prose(body: Option<&str>) -> Prose { - Prose::new( - IssueTitle::new("kelp").unwrap(), - body.map(|text| MarkdownBody::new(text).unwrap()), - None, - None, - ) - .unwrap() + fn oid(byte: u8) -> Oid { + Oid::from_hex(&(byte as char).to_string().repeat(40)).unwrap() } - const BASE: &str = "5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e"; - const SAMPLE_CID: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; - - fn attachment() -> crate::attachment::Attachment { - crate::attachment::Attachment::new( - knot_types::BlobCid::new(cid::Cid::try_from(SAMPLE_CID).unwrap()).unwrap(), - crate::attachment::AttachmentMime::new("image/png").unwrap(), - crate::attachment::AttachmentSize::new(1_000_000).unwrap(), - ) - } - const HEAD: &str = "7c3e9f1a2b3c4d5e6f708192a3b4c5d6e7f80912"; - const OTHER: &str = "9e8d7c6b5a4938271605f4e3d2c1b0a998877665"; - - fn version(head: &str, at: i64) -> PullVersion { - PullVersion::stamped( - PullVersions { - base: Oid::from_hex(BASE).unwrap(), - head: Oid::from_hex(head).unwrap(), - }, - UnixSeconds::new(at), - ) - .unwrap() - } - - fn pull(versions: Vec) -> Result { - PullRecord::new( - squid(), - prose(None), - BranchName::new("main").unwrap(), - None, - None, - versions, - None, - UnixSeconds::new(1_755_090_096), - editor(), - ) - } - - fn wired(record: &PullRecord) -> serde_json::Value { - serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap() - } - - #[test] - fn pull_record_rejects_an_empty_or_overlong_version_list() { - assert!(pull(Vec::new()).is_err()); - let beyond: Vec = (0..=MAX_VERSIONS) - .map(|at| version(HEAD, 1_755_090_096 + at as i64)) - .collect(); - assert!(pull(beyond).is_err()); + fn range() -> CommitRange { + CommitRange { + base: oid(b'a'), + head: oid(b'b'), + } } #[test] - fn encoded_pull_omits_absent_fields() { - let full = PullRecord::new( - squid(), - prose(Some("forest")), - BranchName::new("main").unwrap(), - Some(PullSource { - branch: BranchName::new("kelp-forest").unwrap(), - repo: Some(squid()), - }), - Some( - AtUri::new("at://did:plc:squid/sh.tangled.repo.pull/3lubrptx57d22".to_string()) - .unwrap(), - ), - vec![version(HEAD, 1_755_090_096), version(OTHER, 1_755_090_100)], - None, - UnixSeconds::new(1_755_090_096), + fn patch_record_bytes_frozen() { + let record = PatchRecord::new( + Tid::new("3lubrptx57d22").unwrap(), + range(), + UnixSeconds::new(1), editor(), ) .unwrap(); - let value = wired(&full); - assert_eq!(value["$type"], PULL_COLLECTION); - assert_eq!(value["rounds"], serde_json::json!([])); - assert_eq!(value["target"]["repo"], squid().as_str()); - assert_eq!(value["target"]["branch"], "main"); - assert_eq!(value["source"]["branch"], "kelp-forest"); - assert_eq!(value["source"]["repo"], squid().as_str()); + let body = record.body().unwrap(); assert_eq!( - value["dependentOn"], - "at://did:plc:squid/sh.tangled.repo.pull/3lubrptx57d22" + body.as_bytes(), + frozen(PATCH).as_slice(), + "the first emitted patch must freeze these bytes: whole seconds, utc z" ); - assert_eq!(value["versions"].as_array().unwrap().len(), 2); - assert_eq!(value["versions"][0]["base"], BASE); - assert_eq!(value["versions"][1]["head"], OTHER); - assert_eq!(value["x-tngl-editor"], editor().as_str()); - assert_eq!(value.get("blobs"), None); - - let value = wired(&pull(vec![version(HEAD, 1_755_090_096)]).unwrap()); - assert_eq!(value.get("body"), None); - assert_eq!(value.get("source"), None); - assert_eq!(value.get("dependentOn"), None); - assert_eq!(value.get("mentions"), None); - assert_eq!(value.get("blobs"), None); - } - - #[derive(Deserialize)] - struct BlobsOnly { - #[serde(default)] - blobs: Option>, - } - - #[test] - fn encoded_pull_lists_blobs_as_stock_blob_refs() { - let record = PullRecord::new( - squid(), - prose(Some("forest")), - BranchName::new("main").unwrap(), - None, - None, - vec![version(HEAD, 1_755_090_096)], - Some(vec![attachment()]), - UnixSeconds::new(1_755_090_096), - editor(), - ) - .unwrap(); - let read: BlobsOnly = - serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); - let blobs = read.blobs.expect("the encoded pull lists the blobs"); - assert_eq!(blobs.len(), 1); - assert_eq!(blobs[0].cid().cid().to_string(), SAMPLE_CID); - assert_eq!(blobs[0].mime().as_str(), "image/png"); - assert_eq!(blobs[0].size().get(), 1_000_000); } #[test] - fn encoded_status_has_token_and_pull_uri() { - let record = PullStatusRecord::new( - squid(), - RecordAddress::new(pull_collection(), RecordRkey::new("3lubrptx57d22").unwrap()), - PullStatus::Merged(MergeMove { - branch: RefName::new("refs/heads/main").unwrap(), - old: Oid::from_hex(BASE).unwrap(), - new: Oid::from_hex(HEAD).unwrap(), - }), - UnixSeconds::new(1_755_090_096), + fn patch_embeds_ticket_and_oid_pair() { + let record = PatchRecord::new( + Tid::new("3lubrptx57d22").unwrap(), + range(), + UnixSeconds::new(1), editor(), ) .unwrap(); let value: serde_json::Value = serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); - assert_eq!(value["$type"], PULL_STATUS_COLLECTION); - assert_eq!(value["status"], PullStatus::MERGED_TOKEN); - assert_eq!( - value["pull"], - "at://did:plc:squid/sh.tangled.repo.pull/3lubrptx57d22" - ); + assert_eq!(value["$type"], PATCH_COLLECTION); + assert_eq!(value["ticket"], "3lubrptx57d22"); + assert_eq!(value["base"]["oid"], "a".repeat(40)); + assert_eq!(value["head"]["oid"], "b".repeat(40)); assert_eq!(value["x-tngl-editor"], editor().as_str()); } - - #[test] - fn status_record_rejects_a_non_pull_subject() { - let rejected = PullStatusRecord::new( - squid(), - RecordAddress::new( - knot_types::RecordCollection::new("sh.tangled.repo.issue").unwrap(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ), - PullStatus::Open, - UnixSeconds::new(1_755_090_096), - editor(), - ); - assert!(rejected.is_err()); - } } diff --git a/knot2/crates/knot-record/src/reaction.rs b/knot2/crates/knot-record/src/reaction.rs index 96410e523..95a7f4ffc 100644 --- a/knot2/crates/knot-record/src/reaction.rs +++ b/knot2/crates/knot-record/src/reaction.rs @@ -1,16 +1,16 @@ -use knot_types::{ - AccountDid, AtUri, RecordAddress, RecordBody, RecordBodyError, RepoDid, UnixSeconds, -}; +use knot_types::{AccountDid, RecordBody, RecordBodyError, RepoDid, UnixSeconds}; use serde::{Deserialize, Serialize, Serializer}; +use crate::comment::{CommentRef, CommentRefEncoding, ref_encoding}; use crate::{CreatedAt, RecordError, encoded}; -pub const REACTION_COLLECTION: &str = "sh.tangled.feed.reaction"; +pub const REACTION_COLLECTION: &str = "org.tangled.feed.reaction"; -pub fn reaction_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(REACTION_COLLECTION) - .expect("Reaction collection is valid nsid") -} +collection!( + reaction_collection, + REACTION_COLLECTION, + "Reaction collection is valid nsid" +); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ReactionKind { @@ -35,7 +35,7 @@ pub enum ReactionKind { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReactionRecord { repo: RepoDid, - subject: RecordAddress, + subject: CommentRef, kind: ReactionKind, created_at: CreatedAt, editor: AccountDid, @@ -44,7 +44,7 @@ pub struct ReactionRecord { impl ReactionRecord { pub fn new( repo: RepoDid, - subject: RecordAddress, + subject: CommentRef, kind: ReactionKind, created_at: UnixSeconds, editor: AccountDid, @@ -67,7 +67,7 @@ impl ReactionRecord { struct ReactionEncoding<'a> { #[serde(rename = "$type")] record_type: knot_types::RecordCollection, - subject: AtUri, + subject: CommentRefEncoding, reaction: &'a ReactionKind, #[serde(rename = "createdAt")] created_at: &'a CreatedAt, @@ -79,7 +79,7 @@ impl Serialize for ReactionRecord { fn serialize(&self, serializer: S) -> Result { ReactionEncoding { record_type: reaction_collection(), - subject: self.subject.at_uri(&self.repo), + subject: ref_encoding(&self.subject, &self.repo), reaction: &self.kind, created_at: &self.created_at, editor: &self.editor, @@ -92,24 +92,9 @@ impl Serialize for ReactionRecord { mod tests { use super::*; use crate::frozen; - use knot_types::RecordRkey; - - const REACTION: &str = "a5652474797065781873682e74616e676c65642e666565642e7265616374696f6e677375626a656374783661743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e7265706f2e69737375652f336c7562727074783537643232687265616374696f6e66e29da4efb88f6963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - - fn editor() -> AccountDid { - AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() - } + use crate::test_support::{editor, squid, subject}; - fn squid() -> RepoDid { - RepoDid::new("did:plc:squid").unwrap() - } - - fn subject() -> RecordAddress { - RecordAddress::new( - crate::issue::issue_collection(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ) - } + const REACTION: &str = "a565247479706578196f72672e74616e676c65642e666565642e7265616374696f6e677375626a656374a263636964d82a58250001711220a9491f4c1bf7b0cffbadcba2db8f028e4b3f2867cb59e1f3a0bc1968f3c5124263757269783961743a2f2f6469643a706c633a73717569642f6f72672e74616e676c65642e747261636b2e7469636b65742f336c7562727074783537643232687265616374696f6e66e29da4efb88f6963726561746564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; #[test] fn reaction_record_bytes_frozen() { @@ -125,8 +110,7 @@ mod tests { assert_eq!( body.as_bytes(), frozen(REACTION).as_slice(), - "first reaction ever written should produce exactly these bytes: keys ordered by \ - length, whole seconds, & utc z" + "first reaction ever written should produce exactly these bytes: whole seconds, & utc z" ); } @@ -143,12 +127,10 @@ mod tests { (ReactionKind::Eyes, "👀"), ]; for (kind, token) in pairs { - let record = - ReactionRecord::new(squid(), subject(), kind, UnixSeconds::new(1), editor()) - .unwrap(); - let value: serde_json::Value = - serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); - assert_eq!(value["reaction"], token); + assert_eq!( + serde_json::to_value(kind).unwrap(), + serde_json::json!(token) + ); } } diff --git a/knot2/crates/knot-record/src/ticket.rs b/knot2/crates/knot-record/src/ticket.rs new file mode 100644 index 000000000..c6b14f41c --- /dev/null +++ b/knot2/crates/knot-record/src/ticket.rs @@ -0,0 +1,360 @@ +use std::fmt; +use std::str::FromStr; + +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use knot_types::{AccountDid, AtUri, BranchName, RecordBody, RecordBodyError, RefName, UnixSeconds}; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::comment::{MarkdownEncoding, markdown_encoding}; +use crate::{CreatedAt, Prose, RecordError, encoded}; + +pub const TICKET_COLLECTION: &str = "org.tangled.track.ticket"; + +pub const INLINE_MARKUP: &str = "org.tangled.markup.markdown#inline"; + +collection!( + ticket_collection, + TICKET_COLLECTION, + "Ticket collection is valid nsid" +); + +#[derive(Debug, thiserror::Error)] +pub enum ProseError { + #[error("title is empty")] + EmptyTitle, + #[error("title of {0} bytes is over the {MAX_TITLE_BYTES} byte limit")] + TitleTooLong(usize), + #[error("title of {0} graphemes is over the {MAX_TITLE_GRAPHEMES} grapheme limit")] + TitleTooDense(usize), + #[error("body is empty")] + EmptyBody, + #[error("body of {0} bytes is over the {MAX_BODY_BYTES} byte limit")] + BodyTooLong(usize), + #[error("body of {0} graphemes is over the {MAX_BODY_GRAPHEMES} grapheme limit")] + BodyTooDense(usize), + #[error("{0} mentions are over the {MAX_MENTIONS} limit")] + TooManyMentions(usize), + #[error("{0} references are over the {MAX_REFERENCES} limit")] + TooManyReferences(usize), +} + +pub const MAX_TITLE_BYTES: usize = 1_024; + +pub const MAX_TITLE_GRAPHEMES: usize = 256; + +pub const MAX_BODY_BYTES: usize = 262_144; + +pub const MAX_BODY_GRAPHEMES: usize = 65_536; + +macro_rules! prose_text { + ( + text = $name:ident, + limit = $limit:expr, + graphemes = $graphemes:expr, + empty = $empty:ident, + long = $long:ident, + dense = $dense:ident + ) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(text: impl Into) -> Result { + let text = text.into(); + match (text.trim().is_empty(), text.len()) { + (true, _) => Err(ProseError::$empty), + (false, len) if len > $limit => Err(ProseError::$long(len)), + (false, _) => match UnicodeSegmentation::graphemes(text.as_str(), true).count() + { + counted if counted > $graphemes => Err(ProseError::$dense(counted)), + _ => Ok(Self(text)), + }, + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::new(text).map_err(serde::de::Error::custom) + } + } + }; +} + +prose_text!( + text = TicketTitle, + limit = MAX_TITLE_BYTES, + graphemes = MAX_TITLE_GRAPHEMES, + empty = EmptyTitle, + long = TitleTooLong, + dense = TitleTooDense +); + +prose_text!( + text = MarkdownBody, + limit = MAX_BODY_BYTES, + graphemes = MAX_BODY_GRAPHEMES, + empty = EmptyBody, + long = BodyTooLong, + dense = BodyTooDense +); + +pub const MAX_MENTIONS: usize = 64; + +pub const MAX_REFERENCES: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TicketState { + #[serde(rename = "open")] + Open, + #[serde(rename = "closed")] + Closed, + #[serde(rename = "completed")] + Completed, +} + +impl fmt::Display for TicketState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(match self { + Self::Open => "open", + Self::Closed => "closed", + Self::Completed => "completed", + }) + } +} + +pub const MAX_TARGET_BRANCH_BYTES: usize = 244; + +#[derive(Debug, thiserror::Error)] +pub enum TargetBranchError { + #[error("target branch isn't a valid branch name: {0}")] + Branch(knot_types::ParseError), + #[error("target branch is the bare branch name, without the refs/ prefix")] + Prefixed, + #[error("target branch of {0} bytes is over the {MAX_TARGET_BRANCH_BYTES} byte limit")] + TooLong(usize), +} + +#[derive(Clone)] +pub struct TargetBranch(BranchName); + +impl TargetBranch { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let branch = BranchName::new(&value).map_err(TargetBranchError::Branch)?; + if value.starts_with("refs/") { + return Err(TargetBranchError::Prefixed); + } + if value.len() > MAX_TARGET_BRANCH_BYTES { + return Err(TargetBranchError::TooLong(value.len())); + } + Ok(Self(branch)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn head_ref(&self) -> RefName { + self.0.head_ref() + } +} + +knot_types::string_id!(@traits_for TargetBranch, TargetBranchError); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRecord { + body: Option, + title: TicketTitle, + state: TicketState, + tangled_labels: Option>, + target_branch: Option, + mentions: Option>, + created_at: CreatedAt, + references: Option>>, + attachments: Option>, + editor: AccountDid, +} + +impl TicketRecord { + pub fn new( + prose: Prose, + state: TicketState, + tangled_labels: Option>, + target_branch: Option, + attachments: Option>, + created_at: UnixSeconds, + editor: AccountDid, + ) -> Result { + Ok(Self { + body: prose.body, + title: prose.title, + state, + tangled_labels, + target_branch, + mentions: prose.mentions, + created_at: CreatedAt::at(created_at)?, + references: prose.references, + attachments, + editor, + }) + } + + pub fn body(&self) -> Result { + encoded(self) + } +} + +#[derive(Serialize)] +struct InlineEncoding<'a> { + #[serde(rename = "$type")] + markup_type: &'static str, + text: &'a TicketTitle, +} + +#[derive(Serialize)] +struct TicketEncoding<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(rename = "$type")] + record_type: knot_types::RecordCollection, + title: InlineEncoding<'a>, + state: &'a TicketState, + #[serde(skip_serializing_if = "Option::is_none", rename = "tangledLabels")] + tangled_labels: &'a Option>, + #[serde(skip_serializing_if = "Option::is_none", rename = "targetBranch")] + target_branch: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + mentions: &'a Option>, + #[serde(rename = "createdAt")] + created_at: &'a CreatedAt, + #[serde(skip_serializing_if = "Option::is_none")] + references: &'a Option>>, + #[serde(rename = "x-tngl-editor")] + editor: &'a AccountDid, +} + +impl Serialize for TicketRecord { + fn serialize(&self, serializer: S) -> Result { + TicketEncoding { + body: self + .body + .as_ref() + .map(|body| markdown_encoding(body, &self.attachments)), + record_type: ticket_collection(), + title: InlineEncoding { + markup_type: INLINE_MARKUP, + text: &self.title, + }, + state: &self.state, + tangled_labels: &self.tangled_labels, + target_branch: &self.target_branch, + mentions: &self.mentions, + created_at: &self.created_at, + references: &self.references, + editor: &self.editor, + } + .serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frozen; + + const TICKET: &str = "a664626f6479a2647465787466666f72657374652474797065781b6f72672e74616e676c65642e6d61726b75702e6d61726b646f776e65247479706578186f72672e74616e676c65642e747261636b2e7469636b6574657374617465646f70656e657469746c65a26474657874646b656c7065247479706578226f72672e74616e676c65642e6d61726b75702e6d61726b646f776e23696e6c696e656963726561746564417474323032352d30382d31335431333a30313a33365a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; + + use crate::test_support::editor; + + fn prose(body: Option<&str>, mentions: Option>) -> Prose { + Prose::new( + TicketTitle::new("kelp").unwrap(), + body.map(|text| MarkdownBody::new(text).unwrap()), + mentions, + None, + ) + .unwrap() + } + + #[test] + fn ticket_record_bytes_frozen() { + let record = TicketRecord::new( + prose(Some("forest"), None), + TicketState::Open, + None, + None, + None, + UnixSeconds::new(1_755_090_096), + editor(), + ) + .unwrap(); + let body = record.body().unwrap(); + assert_eq!( + body.as_bytes(), + frozen(TICKET).as_slice(), + "the first emitted ticket must freeze these bytes: absent optionals omitted, seconds precision, utc z" + ); + } + + #[test] + fn absent_body_is_omitted_but_sent_empty_list_is_kept() { + let sent = prose(None, Some(Vec::new())); + let record = TicketRecord::new( + sent, + TicketState::Open, + None, + None, + None, + UnixSeconds::new(1_755_090_096), + editor(), + ) + .unwrap(); + let bytes = record.body().unwrap(); + let value: serde_json::Value = serde_ipld_dagcbor::from_slice(bytes.as_bytes()).unwrap(); + assert_eq!(value.get("body"), None); + assert_eq!(value["mentions"], serde_json::json!([])); + assert_eq!(value.get("references"), None); + } + + #[test] + fn ticket_state_spells_the_three_tokens() { + assert_eq!(TicketState::Open.to_string(), "open"); + assert_eq!(TicketState::Closed.to_string(), "closed"); + assert_eq!(TicketState::Completed.to_string(), "completed"); + } + + #[test] + fn ticket_prose_must_say_something_and_not_too_much() { + assert!(matches!( + TicketTitle::new(" "), + Err(ProseError::EmptyTitle) + )); + assert!(matches!( + MarkdownBody::new("\n"), + Err(ProseError::EmptyBody) + )); + assert!(matches!( + TicketTitle::new("k".repeat(MAX_TITLE_BYTES + 1)), + Err(ProseError::TitleTooLong(_)) + )); + assert!(matches!( + MarkdownBody::new("k".repeat(MAX_BODY_BYTES + 1)), + Err(ProseError::BodyTooLong(_)) + )); + assert!(matches!( + TicketTitle::new("e\u{301}".repeat(MAX_TITLE_GRAPHEMES + 1)), + Err(ProseError::TitleTooDense(_)) + )); + assert!(matches!( + MarkdownBody::new("e\u{301}".repeat(MAX_BODY_GRAPHEMES + 1)), + Err(ProseError::BodyTooDense(_)) + )); + } +} diff --git a/knot2/crates/knot-sim/src/realdata.rs b/knot2/crates/knot-sim/src/realdata.rs index 5e175a6b9..2cca72f0c 100644 --- a/knot2/crates/knot-sim/src/realdata.rs +++ b/knot2/crates/knot-sim/src/realdata.rs @@ -388,7 +388,7 @@ fn skew_fault(skew: bool) -> &'static str { fn reads(working: &[RepoDid], rng: &Rng) -> Vec { let mut execs: Vec = [ - ("version", "/xrpc/sh.tangled.knot.version"), + ("version", "/xrpc/org.tangled.knot.describeKnot"), ("owner", "/xrpc/sh.tangled.owner"), ("didJson", "/.well-known/did.json"), ] diff --git a/knot2/crates/knot-sim/src/workload.rs b/knot2/crates/knot-sim/src/workload.rs index 12908d6f4..a6ed57707 100644 --- a/knot2/crates/knot-sim/src/workload.rs +++ b/knot2/crates/knot-sim/src/workload.rs @@ -575,7 +575,7 @@ pub(crate) struct Request { fn read_request(repos: &[RepoDid], op: ReadOp) -> Request { match op { - ReadOp::Version => get("/xrpc/sh.tangled.knot.version"), + ReadOp::Version => get("/xrpc/org.tangled.knot.describeKnot"), ReadOp::Owner => get("/xrpc/sh.tangled.owner"), ReadOp::ListMembers => { get("/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet") diff --git a/knot2/crates/knot-xrpc/src/blobs.rs b/knot2/crates/knot-xrpc/src/blobs.rs index 35ce66b1b..4cbe169b0 100644 --- a/knot2/crates/knot-xrpc/src/blobs.rs +++ b/knot2/crates/knot-xrpc/src/blobs.rs @@ -348,10 +348,16 @@ fn blob_cids_of(record: &Ipld) -> Vec { _ => [].iter(), }) .filter_map(|item| match item { - Ipld::Map(blob) => match blob.get("ref") { - Some(Ipld::Link(cid)) => BlobCid::new(*cid), - _ => None, - }, + Ipld::Map(blob) => blob + .get("image") + .or_else(|| blob.get("video")) + .and_then(|inner| match inner { + Ipld::Map(fields) => match fields.get("ref") { + Some(Ipld::Link(cid)) => BlobCid::new(*cid), + _ => None, + }, + _ => None, + }), _ => None, }) .collect() diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 885fc5d02..5be519dbf 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -430,7 +430,7 @@ pub fn router(state: Arc>) -> Router lists::LIST_COLLABORATORS_ROUTE, get(lists::list_collaborators::), ) - .route(service::VERSION_ROUTE, get(service::version)) + .route(service::VERSION_ROUTE, get(service::version::)) .route(service::OWNER_ROUTE, get(service::owner::)) .route( "/xrpc/com.atproto.sync.getRepo", diff --git a/knot2/crates/knot-xrpc/src/service.rs b/knot2/crates/knot-xrpc/src/service.rs index ee6ad15f9..551e3b988 100644 --- a/knot2/crates/knot-xrpc/src/service.rs +++ b/knot2/crates/knot-xrpc/src/service.rs @@ -11,7 +11,7 @@ use knot_types::AccountDid; use crate::XrpcState; -pub(crate) const VERSION_ROUTE: &str = "/xrpc/sh.tangled.knot.version"; +pub(crate) const VERSION_ROUTE: &str = "/xrpc/org.tangled.knot.describeKnot"; pub(crate) const OWNER_ROUTE: &str = "/xrpc/sh.tangled.owner"; pub(crate) const HEALTH_ROUTE: &str = "/xrpc/_health"; @@ -19,6 +19,7 @@ const WIRE_VERSION: &str = "v1.15.0"; #[derive(Serialize)] struct VersionWire { + did: String, version: &'static str, capabilities: Vec<&'static str>, } @@ -51,8 +52,11 @@ struct OwnerWire { owner: AccountDid, } -pub(crate) async fn version() -> Response { +pub(crate) async fn version( + State(state): State>>, +) -> Response { Json(VersionWire { + did: state.knot_did.as_str().to_string(), version: WIRE_VERSION, capabilities: Capability::SERVED .iter() diff --git a/knot2/interop/atproto_issue_test.go b/knot2/interop/atproto_issue_test.go deleted file mode 100644 index d8a979440..000000000 --- a/knot2/interop/atproto_issue_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package interop - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "testing" - - comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - cbg "github.com/whyrusleeping/cbor-gen" - "tangled.org/core/api/tangled" -) - -type stringFields map[string]string - -func (fields stringFields) UnmarshalCBOR(r io.Reader) error { - maj, count, err := cbg.CborReadHeader(r) - if err != nil { - return err - } - if maj != cbg.MajMap { - return fmt.Errorf("expected a cbor map, got major type %d", maj) - } - return fields.read(r, count) -} - -func (fields stringFields) read(r io.Reader, remaining uint64) error { - if remaining == 0 { - return nil - } - key, err := cbg.ReadString(r) - if err != nil { - return err - } - value, err := cbg.ReadString(r) - if err != nil { - return err - } - fields[key] = value - return fields.read(r, remaining-1) -} - -func TestKnotServesIssueUnderRepositoryDid(t *testing.T) { - ctx := context.Background() - xc := knotClient(t) - did, err := syntax.ParseDID(os.Getenv("KNOT_TEST_REPO_DID")) - require.NoError(t, err, "KNOT_TEST_REPO_DID must be a did") - collection, err := syntax.ParseNSID("sh.tangled.repo.issue") - require.NoError(t, err, "the issue collection must parse as an nsid") - rkey, err := syntax.ParseRecordKey(os.Getenv("KNOT_TEST_ISSUE_RKEY")) - require.NoError(t, err, "KNOT_TEST_ISSUE_RKEY must be a record key") - editor := os.Getenv("KNOT_TEST_EDITOR") - - dir := directory(t, "KNOT_TEST_REPO_DID", "KNOT_TEST_REPO_DID_DOC") - _, full, recordCID := provenRecord(t, ctx, xc, dir, did, collection, rkey) - assert.Equal(t, os.Getenv("KNOT_TEST_ISSUE_CID"), recordCID.String(), "the cid the write answered should be the one in the tree") - - raw, _, err := full.GetRecordBytes(ctx, collection, rkey) - require.NoError(t, err, "getRepo serves the issue leaf like any other block") - stored := stringFields{} - require.NoError(t, stored.UnmarshalCBOR(bytes.NewReader(raw))) - assert.Equal(t, collection.String(), stored["$type"]) - assert.Equal(t, did.String(), stored["repo"]) - assert.Equal(t, "kelp", stored["title"]) - assert.Equal(t, "a forest", stored["body"]) - assert.Equal(t, editor, stored["x-tngl-editor"], "the block must identify who wrote it") - - blocksCar, err := comatproto.SyncGetBlocks(ctx, xc, []string{recordCID.String()}, did.String()) - require.NoError(t, err, "sync.getBlocks answers the leaf by cid") - assert.Contains(t, string(blocksCar), "a forest", "the leaf must come back by cid") - - records, err := comatproto.RepoListRecords(ctx, xc, collection.String(), "", 50, did.String(), false) - require.NoError(t, err, "repo.listRecords pages the collection") - require.Len(t, records.Records, 1) - assert.Equal(t, "at://"+did.String()+"/"+collection.String()+"/"+rkey.String(), records.Records[0].Uri) - issue, ok := records.Records[0].Value.Val.(*tangled.RepoIssue) - require.True(t, ok, "the value decodes as the generated lexicon type") - assert.Equal(t, "kelp", issue.Title) - require.NotNil(t, issue.Body) - assert.Equal(t, "a forest", *issue.Body) - assert.Equal(t, did.String(), issue.Repo) - - record, err := comatproto.RepoGetRecord(ctx, xc, "", collection.String(), did.String(), rkey.String()) - require.NoError(t, err, "repo.getRecord answers the at-uri") - require.NotNil(t, record.Cid) - assert.Equal(t, recordCID.String(), *record.Cid) - - describe, err := comatproto.RepoDescribeRepo(ctx, xc, did.String()) - require.NoError(t, err) - assert.Contains(t, lo.Map(describe.Collections, func(row string, _ int) string { return row }), collection.String()) -} diff --git a/knot2/interop/atproto_reads_test.go b/knot2/interop/atproto_reads_test.go index 0a853a5c3..66385e9df 100644 --- a/knot2/interop/atproto_reads_test.go +++ b/knot2/interop/atproto_reads_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "net/http" "os" "testing" @@ -18,8 +20,38 @@ import ( "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cbg "github.com/whyrusleeping/cbor-gen" ) +type stringFields map[string]string + +func (fields stringFields) UnmarshalCBOR(r io.Reader) error { + maj, count, err := cbg.CborReadHeader(r) + if err != nil { + return err + } + if maj != cbg.MajMap { + return fmt.Errorf("expected a cbor map, got major type %d", maj) + } + return fields.read(r, count) +} + +func (fields stringFields) read(r io.Reader, remaining uint64) error { + if remaining == 0 { + return nil + } + key, err := cbg.ReadString(r) + if err != nil { + return err + } + value, err := cbg.ReadString(r) + if err != nil { + return err + } + fields[key] = value + return fields.read(r, remaining-1) +} + func knotClient(t *testing.T) *indigoxrpc.Client { t.Helper() return &indigoxrpc.Client{ diff --git a/knot2/interop/atproto_ticket_test.go b/knot2/interop/atproto_ticket_test.go new file mode 100644 index 000000000..612e8eb22 --- /dev/null +++ b/knot2/interop/atproto_ticket_test.go @@ -0,0 +1,173 @@ +package interop + +import ( + "bufio" + "bytes" + "context" + "os" + "testing" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/repo" + "github.com/bluesky-social/indigo/atproto/syntax" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cbg "github.com/whyrusleeping/cbor-gen" +) + +const ticketCollection = "org.tangled.track.ticket" +const patchCollection = "org.tangled.review.patch" + +type ticketRecord struct { + Records []struct { + Uri string `json:"uri"` + Cid string `json:"cid"` + Value struct { + Type string `json:"$type"` + State string `json:"state"` + TargetBranch string `json:"targetBranch"` + Title struct { + Type string `json:"$type"` + Text string `json:"text"` + } `json:"title"` + CreatedAt string `json:"createdAt"` + Editor string `json:"x-tngl-editor"` + } `json:"value"` + } `json:"records"` +} + +type patchRecord struct { + Uri string `json:"uri"` + Cid string `json:"cid"` + Value struct { + Type string `json:"$type"` + Ticket string `json:"ticket"` + Base struct { + Oid string `json:"oid"` + } `json:"base"` + Head struct { + Oid string `json:"oid"` + } `json:"head"` + CreatedAt string `json:"createdAt"` + Editor string `json:"x-tngl-editor"` + } `json:"value"` +} + +func leafFields(t *testing.T, raw []byte) (stringFields, map[string]stringFields) { + t.Helper() + reader := bufio.NewReader(bytes.NewReader(raw)) + maj, pairs, err := cbg.CborReadHeader(reader) + require.NoError(t, err, "the ticket leaf opens as a cbor map") + require.Equal(t, byte(cbg.MajMap), maj, "the ticket leaf is a cbor map") + flat := stringFields{} + unions := map[string]stringFields{} + for i := uint64(0); i < pairs; i++ { + key, err := cbg.ReadString(reader) + require.NoError(t, err, "the leaf's keys are text strings") + head, err := reader.Peek(1) + require.NoError(t, err, "every leaf field has a value") + switch head[0] >> 5 { + case cbg.MajTextString: + value, err := cbg.ReadString(reader) + require.NoError(t, err, "the leaf's %s is a text string", key) + flat[key] = value + case cbg.MajMap: + union := stringFields{} + require.NoError(t, union.UnmarshalCBOR(reader), "the leaf's %s is a map of text fields", key) + unions[key] = union + default: + t.Fatalf("unexpected cbor major type %d for leaf field %s", head[0]>>5, key) + } + } + return flat, unions +} + +func TestKnotServesTicketsAndPatchesInTheOrgTangledShapes(t *testing.T) { + ctx := context.Background() + xc := knotClient(t) + did, err := syntax.ParseDID(os.Getenv("KNOT_TEST_REPO_DID")) + require.NoError(t, err, "KNOT_TEST_REPO_DID must be a did") + ticketRkey, err := syntax.ParseRecordKey(os.Getenv("KNOT_TEST_TICKET_RKEY")) + require.NoError(t, err, "KNOT_TEST_TICKET_RKEY must be a record key") + ticketCID := os.Getenv("KNOT_TEST_TICKET_CID") + require.NotEmpty(t, ticketCID, "KNOT_TEST_TICKET_CID must be the cid the write returned") + patchRkey, err := syntax.ParseRecordKey(os.Getenv("KNOT_TEST_PATCH_RKEY")) + require.NoError(t, err, "KNOT_TEST_PATCH_RKEY must be a record key") + patchCID := os.Getenv("KNOT_TEST_PATCH_CID") + require.NotEmpty(t, patchCID, "KNOT_TEST_PATCH_CID must be the cid the write returned") + title := os.Getenv("KNOT_TEST_TICKET_TITLE") + editor := os.Getenv("KNOT_TEST_EDITOR") + var tickets ticketRecord + err = xc.LexDo(ctx, indigoxrpc.Query, "", "com.atproto.repo.listRecords", map[string]any{ + "repo": did.String(), + "collection": ticketCollection, + "limit": 50, + }, nil, &tickets) + require.NoError(t, err, "repo.listRecords pages the ticket collection") + found := false + for _, row := range tickets.Records { + if row.Uri != "at://"+did.String()+"/"+ticketCollection+"/"+ticketRkey.String() { + continue + } + found = true + assert.Equal(t, ticketCID, row.Cid, "the listed ticket has the cid the write returned") + assert.Equal(t, ticketCollection, row.Value.Type) + assert.Equal(t, "org.tangled.markup.markdown#inline", row.Value.Title.Type, + "a ticket's title is the inline markdown union") + assert.Contains(t, []string{"open", "closed", "completed"}, row.Value.State, + "a ticket's state is one of the three track.defs tokens") + assert.Equal(t, title, row.Value.Title.Text, "the served ticket keeps the written title") + assert.NotEmpty(t, row.Value.CreatedAt) + assert.Equal(t, editor, row.Value.Editor, "the knot serves the undeclared editor field") + } + assert.True(t, found, "the wanted ticket must be among the listed records") + + var patch patchRecord + err = xc.LexDo(ctx, indigoxrpc.Query, "", "com.atproto.repo.getRecord", map[string]any{ + "repo": did.String(), + "collection": patchCollection, + "rkey": patchRkey.String(), + }, nil, &patch) + require.NoError(t, err, "repo.getRecord serves the patch's at-uri") + assert.Equal(t, patchCollection, patch.Value.Type) + assert.Equal(t, patchCID, patch.Cid, "the served patch has the cid the write returned") + assert.Equal(t, patchRkey.String(), patch.Cid, + "a patch's record key is the CID of its own record") + assert.NotEmpty(t, patch.Value.Ticket, "a patch cites its ticket's rkey") + for _, oid := range []string{patch.Value.Base.Oid, patch.Value.Head.Oid} { + length := len(oid) + assert.Contains(t, []int{40, 64}, length, "a patch's oids are hex object ids: %s", oid) + } + + collection, err := syntax.ParseNSID(ticketCollection) + require.NoError(t, err, "the ticket collection must parse as an nsid") + repoCar, err := comatproto.SyncGetRepo(ctx, xc, did.String(), "") + require.NoError(t, err, "sync.getRepo serves the ticket tree") + _, full, err := repo.LoadRepoFromCAR(ctx, bytes.NewReader(repoCar)) + require.NoError(t, err, "the whole tree loads from the getRepo car") + raw, leafCID, err := full.GetRecordBytes(ctx, collection, ticketRkey) + require.NoError(t, err, "getRepo serves the ticket leaf like any other block") + require.NotNil(t, leafCID) + assert.Equal(t, ticketCID, leafCID.String(), "the block walk resolves the cid the write returned") + stored, unions := leafFields(t, raw) + assert.Equal(t, ticketCollection, stored["$type"]) + assert.Contains(t, []string{"open", "closed", "completed"}, stored["state"], + "a ticket's state is one of the three track.defs tokens") + assert.Equal(t, editor, stored["x-tngl-editor"], "the block must identify who wrote it") + storedTitle, ok := unions["title"] + require.True(t, ok, "the leaf stores the title as the inline markup union") + assert.Equal(t, "org.tangled.markup.markdown#inline", storedTitle["$type"]) + assert.Equal(t, title, storedTitle["text"], "the block bytes keep the written title") + + blocksCar, err := comatproto.SyncGetBlocks(ctx, xc, []string{ticketCID}, did.String()) + require.NoError(t, err, "sync.getBlocks serves the ticket leaf by cid") + assert.Contains(t, string(blocksCar), "a forest", "the leaf must come back by cid") + + describe, err := comatproto.RepoDescribeRepo(ctx, xc, did.String()) + require.NoError(t, err, "repo.describeRepo serves the knot's repo") + collections := lo.Map(describe.Collections, func(row string, _ int) string { return row }) + assert.Contains(t, collections, ticketCollection, "describeRepo lists the ticket collection") + assert.Contains(t, collections, patchCollection, "describeRepo lists the patch collection") +} diff --git a/lexicons/org/tangled/review/unspecced/round.json b/lexicons/org/tangled/review/unspecced/round.json index 6b2fe4a36..509fb9673 100644 --- a/lexicons/org/tangled/review/unspecced/round.json +++ b/lexicons/org/tangled/review/unspecced/round.json @@ -18,7 +18,7 @@ "subject": { "type": "string", "format": "record-key", - "description": "ticket-id", + "description": "ticket-id" }, "commit": { "type": "ref", diff --git a/lexicons/org/tangled/snippet/string.json b/lexicons/org/tangled/snippet/string.json index 1267009b7..a5e3585cf 100644 --- a/lexicons/org/tangled/snippet/string.json +++ b/lexicons/org/tangled/snippet/string.json @@ -15,7 +15,7 @@ "maxGraphemes": 256 }, "snippet": { - "type": "union" + "type": "union", "refs": ["#textFile"] }, "createdAt": { @@ -35,7 +35,7 @@ "maxGraphemes": 140 }, "content": { - "type": "blob" + "type": "blob", "accept": ["text/plain"], "maxLength": 1000000 } -- 2.51.2