This repository has no description
1use alloc::collections::BTreeMap;
2use bobbin_types::com_atproto::repo::strong_ref::StrongRef;
3use bobbin_types::edges::{ExtractError, Record};
4use bobbin_types::legacy::{
5 LEGACY_COMMENT_SENTINEL_CID, LegacyCollaborator, LegacyIssue, LegacyIssueComment,
6 LegacyKnotMember, LegacyPublicKey, LegacyPull, LegacyPullComment, LegacyRecord,
7 LegacyRefUpdate, LegacyRepo, LegacySource, LegacyStar, LegacyTarget,
8};
9use bobbin_types::sh_tangled::feed::comment::Comment as FeedComment;
10use bobbin_types::sh_tangled::feed::star::{Repo as StarRepo, Star, StarString, StarSubject};
11use bobbin_types::sh_tangled::git::ref_update::RefUpdate;
12use bobbin_types::sh_tangled::knot::member::Member as KnotMember;
13use bobbin_types::sh_tangled::markup::markdown::Markdown;
14use bobbin_types::sh_tangled::public_key::PublicKey;
15use bobbin_types::sh_tangled::repo::Repo;
16use bobbin_types::sh_tangled::repo::collaborator::Collaborator;
17use bobbin_types::sh_tangled::repo::issue::Issue;
18use bobbin_types::sh_tangled::repo::pull::{Pull, Round, Source, Target};
19use jacquard_common::deps::smol_str::SmolStr;
20use jacquard_common::types::did::Did;
21use jacquard_common::types::nsid::Nsid;
22use jacquard_common::types::string::{AtUri, AtprotoStr, Cid};
23use jacquard_common::types::value::{Array, Data};
24use jacquard_common::{BosStr, DefaultStr};
25
26use crate::normalize::{is_repo_at_uri, resolve_repo_uri};
27use crate::{RepoIdResolver, Resolution};
28use jacquard_common::IntoStatic;
29use jacquard_common::types::ident::AtIdentifier;
30use jacquard_common::types::recordkey::Rkey;
31
32#[derive(Debug)]
33pub enum DecodedRecord {
34 Canon(Record),
35 Legacy(LegacyRecord),
36}
37
38impl DecodedRecord {
39 pub fn try_decode<S: BosStr + AsRef<str>>(
40 nsid: &Nsid<S>,
41 bytes: &[u8],
42 ) -> Result<Self, ExtractError> {
43 match Record::from_json_bytes(nsid, bytes) {
44 Ok(record) => Ok(Self::Canon(record)),
45 Err(canon_err) => {
46 let normalized = normalize_record_fields(bytes);
47 let working: &[u8] = normalized.as_deref().unwrap_or(bytes);
48 if normalized.is_some()
49 && let Ok(record) = Record::from_json_bytes(nsid, working)
50 {
51 return Ok(Self::Canon(record));
52 }
53 if let Some(scrubbed) = scrub_record_bytes(nsid, working) {
54 if let Ok(record) = Record::from_json_bytes(nsid, &scrubbed) {
55 return Ok(Self::Canon(record));
56 }
57 if let Ok(legacy) = LegacyRecord::from_json_bytes(nsid, &scrubbed) {
58 return Ok(Self::Legacy(legacy));
59 }
60 }
61 match LegacyRecord::from_json_bytes(nsid, working) {
62 Ok(legacy) => Ok(Self::Legacy(legacy)),
63 Err(_) => Err(canon_err),
64 }
65 }
66 }
67 }
68}
69
70pub fn normalize_record_fields(bytes: &[u8]) -> Option<alloc::vec::Vec<u8>> {
71 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
72 let reserialized = serde_json::to_vec(&value).ok()?;
73 (reserialized.as_slice() != bytes).then_some(reserialized)
74}
75
76pub fn synthesize_created_at(bytes: &[u8], fallback_rfc3339: &str) -> Option<alloc::vec::Vec<u8>> {
77 let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
78 let obj = value.as_object_mut()?;
79 let needs_fill = match obj.get("createdAt") {
80 None => true,
81 Some(serde_json::Value::String(s)) if s.is_empty() => true,
82 _ => false,
83 };
84 if !needs_fill {
85 return None;
86 }
87 obj.insert(
88 "createdAt".to_owned(),
89 serde_json::Value::String(fallback_rfc3339.to_owned()),
90 );
91 serde_json::to_vec(&value).ok()
92}
93
94#[derive(Clone, Copy, Debug)]
95enum FieldRule {
96 DropIfEmptyString,
97 NullToEmptyArray,
98}
99
100fn scrub_rules(nsid: &str) -> &'static [(&'static str, FieldRule)] {
101 match nsid {
102 "sh.tangled.actor.profile" => &[("preferredHandle", FieldRule::DropIfEmptyString)],
103 "sh.tangled.label.op" => &[
104 ("add", FieldRule::NullToEmptyArray),
105 ("delete", FieldRule::NullToEmptyArray),
106 ],
107 "sh.tangled.repo.pull" => &[("rounds", FieldRule::NullToEmptyArray)],
108 _ => &[],
109 }
110}
111
112pub fn scrub_record_bytes<S: BosStr + AsRef<str>>(
113 nsid: &Nsid<S>,
114 bytes: &[u8],
115) -> Option<alloc::vec::Vec<u8>> {
116 let rules = scrub_rules(nsid.as_ref());
117 if rules.is_empty() {
118 return None;
119 }
120 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
121 let mut obj = value.as_object()?.clone();
122 let touched: alloc::vec::Vec<(&str, FieldRule)> = rules
123 .iter()
124 .filter_map(|(field, rule)| match (rule, obj.get(*field)) {
125 (FieldRule::DropIfEmptyString, Some(serde_json::Value::String(s))) if s.is_empty() => {
126 Some((*field, *rule))
127 }
128 (FieldRule::NullToEmptyArray, Some(serde_json::Value::Null)) => Some((*field, *rule)),
129 _ => None,
130 })
131 .collect();
132 if touched.is_empty() {
133 return None;
134 }
135 touched.iter().for_each(|(field, rule)| match rule {
136 FieldRule::DropIfEmptyString => {
137 obj.remove(*field);
138 }
139 FieldRule::NullToEmptyArray => {
140 obj.insert(
141 (*field).to_owned(),
142 serde_json::Value::Array(alloc::vec::Vec::new()),
143 );
144 }
145 });
146 tracing::debug!(nsid = %nsid.as_ref(), ?touched, "scrubbing fields before record retry");
147 serde_json::to_vec(&serde_json::Value::Object(obj)).ok()
148}
149
150async fn upgrade_repo_did(
151 resolver: &RepoIdResolver,
152 at_uri: Option<AtUri<DefaultStr>>,
153 explicit_did: Option<Did<DefaultStr>>,
154) -> Option<Did<DefaultStr>> {
155 if let Some(d) = explicit_did {
156 return Some(d);
157 }
158 let uri = at_uri?;
159 resolve_repo_uri(resolver, &uri).await
160}
161
162pub async fn upgrade_wire_bytes<S: BosStr + AsRef<str>>(
163 nsid: &Nsid<S>,
164 bytes: &[u8],
165 resolver: &RepoIdResolver,
166) -> Result<alloc::vec::Vec<u8>, ExtractError> {
167 let legacy = LegacyRecord::from_json_bytes(nsid, bytes)?;
168 let canon = upgrade(legacy, resolver)
169 .await
170 .ok_or_else(|| upgrade_failed(nsid))?;
171 serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson)
172}
173
174fn upgrade_failed<S: BosStr + AsRef<str>>(nsid: &Nsid<S>) -> ExtractError {
175 ExtractError::UnknownCollection(alloc::format!("{}: legacy upgrade failed", nsid.as_ref()))
176}
177
178pub async fn decode_canon_or_upgrade<S: BosStr + AsRef<str>>(
179 nsid: &Nsid<S>,
180 bytes: &[u8],
181 resolver: &RepoIdResolver,
182) -> Result<Record, ExtractError> {
183 match DecodedRecord::try_decode(nsid, bytes)? {
184 DecodedRecord::Canon(r) => Ok(r),
185 DecodedRecord::Legacy(legacy) => upgrade(legacy, resolver)
186 .await
187 .ok_or_else(|| upgrade_failed(nsid)),
188 }
189}
190
191pub async fn decode_canon_or_upgrade_bytes<'a, S: BosStr + AsRef<str>>(
192 nsid: &Nsid<S>,
193 bytes: &'a [u8],
194 resolver: &RepoIdResolver,
195) -> Result<(Record, alloc::borrow::Cow<'a, [u8]>), ExtractError> {
196 let decoded = DecodedRecord::try_decode(nsid, bytes)?;
197 match decoded {
198 DecodedRecord::Canon(r) => Ok((r, alloc::borrow::Cow::Borrowed(bytes))),
199 DecodedRecord::Legacy(legacy) => {
200 let canon = upgrade(legacy, resolver)
201 .await
202 .ok_or_else(|| upgrade_failed(nsid))?;
203 let canon_bytes = serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson)?;
204 Ok((canon, alloc::borrow::Cow::Owned(canon_bytes)))
205 }
206 }
207}
208
209fn serialize_canon_variant(record: &Record) -> Result<alloc::vec::Vec<u8>, serde_json::Error> {
210 match record {
211 Record::FeedComment(r) => serde_json::to_vec(r),
212 Record::Issue(r) => serde_json::to_vec(r),
213 Record::Pull(r) => serde_json::to_vec(r),
214 Record::Collaborator(r) => serde_json::to_vec(r),
215 Record::RefUpdate(r) => serde_json::to_vec(r),
216 Record::Star(r) => serde_json::to_vec(r),
217 Record::PublicKey(r) => serde_json::to_vec(r),
218 Record::Repo(r) => serde_json::to_vec(r),
219 Record::KnotMember(r) => serde_json::to_vec(r),
220 _ => unreachable!(
221 "upgrade only produces FeedComment/Issue/Pull/Collaborator/RefUpdate/Star/PublicKey/Repo/KnotMember"
222 ),
223 }
224}
225
226pub async fn upgrade(legacy: LegacyRecord, resolver: &RepoIdResolver) -> Option<Record> {
227 match legacy {
228 LegacyRecord::Issue(l) => upgrade_issue(l, resolver).await.map(Record::Issue),
229 LegacyRecord::IssueComment(l) => Some(Record::FeedComment(upgrade_issue_comment(l))),
230 LegacyRecord::Pull(l) => upgrade_pull(l, resolver).await.map(Record::Pull),
231 LegacyRecord::PullComment(l) => Some(Record::FeedComment(upgrade_pull_comment(l))),
232 LegacyRecord::Collaborator(l) => upgrade_collaborator(l, resolver)
233 .await
234 .map(Record::Collaborator),
235 LegacyRecord::RefUpdate(l) => Some(Record::RefUpdate(upgrade_ref_update(l))),
236 LegacyRecord::Star(l) => upgrade_star(l, resolver).await.map(Record::Star),
237 LegacyRecord::PublicKey(l) => Some(Record::PublicKey(upgrade_public_key(l))),
238 LegacyRecord::Repo(l) => Some(Record::Repo(upgrade_repo(l))),
239 LegacyRecord::KnotMember(l) => Some(Record::KnotMember(upgrade_knot_member(l))),
240 }
241}
242
243fn sentinel_strong_ref(uri: AtUri<DefaultStr>) -> StrongRef<DefaultStr> {
244 let cid = Cid::<DefaultStr>::new_owned(LEGACY_COMMENT_SENTINEL_CID.as_bytes())
245 .expect("LEGACY_COMMENT_SENTINEL_CID is a valid CID literal");
246 StrongRef {
247 uri,
248 cid,
249 extra_data: None,
250 }
251}
252
253fn legacy_body_markdown(text: DefaultStr) -> Markdown<DefaultStr> {
254 Markdown {
255 blobs: None,
256 original: None,
257 text,
258 extra_data: None,
259 }
260}
261
262fn upgrade_issue_comment(l: LegacyIssueComment<DefaultStr>) -> FeedComment<DefaultStr> {
263 FeedComment {
264 body: legacy_body_markdown(l.body),
265 created_at: l.created_at,
266 pull_round_idx: None,
267 reply_to: l.reply_to.map(sentinel_strong_ref),
268 subject: sentinel_strong_ref(l.issue),
269 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references),
270 }
271}
272
273fn upgrade_pull_comment(l: LegacyPullComment<DefaultStr>) -> FeedComment<DefaultStr> {
274 FeedComment {
275 body: legacy_body_markdown(l.body),
276 created_at: l.created_at,
277 pull_round_idx: None,
278 reply_to: None,
279 subject: sentinel_strong_ref(l.pull),
280 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references),
281 }
282}
283
284fn legacy_comment_extras<S: BosStr>(
285 base: Option<BTreeMap<SmolStr, Data<S>>>,
286 mentions: Option<Vec<Did<S>>>,
287 references: Option<Vec<AtUri<S>>>,
288) -> Option<BTreeMap<SmolStr, Data<S>>> {
289 let mention_entry = mentions.filter(|v| !v.is_empty()).map(|items| {
290 let arr = items
291 .into_iter()
292 .map(|d| Data::String(AtprotoStr::Did(d)))
293 .collect();
294 (SmolStr::new_static("mentions"), Data::Array(Array(arr)))
295 });
296 let reference_entry = references.filter(|v| !v.is_empty()).map(|items| {
297 let arr = items
298 .into_iter()
299 .map(|u| Data::String(AtprotoStr::AtUri(u)))
300 .collect();
301 (SmolStr::new_static("references"), Data::Array(Array(arr)))
302 });
303 let combined: BTreeMap<SmolStr, Data<S>> = base
304 .into_iter()
305 .flatten()
306 .chain(mention_entry)
307 .chain(reference_entry)
308 .collect();
309 if combined.is_empty() {
310 None
311 } else {
312 Some(combined)
313 }
314}
315
316async fn upgrade_issue(
317 l: LegacyIssue<DefaultStr>,
318 resolver: &RepoIdResolver,
319) -> Option<Issue<DefaultStr>> {
320 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
321 Some(Issue {
322 created_at: l.created_at,
323 blobs: None,
324 body: l.body,
325 mentions: l.mentions,
326 references: l.references,
327 repo,
328 title: l.title,
329 extra_data: l.extra_data,
330 })
331}
332
333async fn upgrade_target(
334 l: LegacyTarget<DefaultStr>,
335 resolver: &RepoIdResolver,
336) -> Option<Target<DefaultStr>> {
337 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
338 Some(Target {
339 branch: l.branch,
340 repo,
341 extra_data: None,
342 })
343}
344
345async fn upgrade_source(
346 l: LegacySource<DefaultStr>,
347 resolver: &RepoIdResolver,
348) -> Source<DefaultStr> {
349 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await;
350 Source {
351 branch: l.branch,
352 repo,
353 extra_data: None,
354 }
355}
356
357async fn upgrade_pull(
358 l: LegacyPull<DefaultStr>,
359 resolver: &RepoIdResolver,
360) -> Option<Pull<DefaultStr>> {
361 let target = upgrade_target(l.target, resolver).await?;
362 let source = match l.source {
363 Some(s) => Some(upgrade_source(s, resolver).await),
364 None => None,
365 };
366 let rounds = if l.rounds.is_empty() {
367 l.patch_blob
368 .map(|patch_blob| {
369 alloc::vec![Round {
370 created_at: l.created_at.clone(),
371 patch_blob,
372 extra_data: None,
373 }]
374 })
375 .unwrap_or_default()
376 } else {
377 l.rounds
378 };
379 Some(Pull {
380 created_at: l.created_at,
381 blobs: None,
382 body: l.body,
383 dependent_on: l.dependent_on,
384 mentions: l.mentions,
385 references: l.references,
386 rounds,
387 source,
388 target,
389 title: l.title,
390 extra_data: l.extra_data,
391 })
392}
393
394fn upgrade_public_key(l: LegacyPublicKey<DefaultStr>) -> PublicKey<DefaultStr> {
395 PublicKey {
396 created_at: l.created,
397 key: l.key,
398 name: l.name,
399 extra_data: l.extra_data,
400 }
401}
402
403fn upgrade_repo(l: LegacyRepo<DefaultStr>) -> Repo<DefaultStr> {
404 let _ = l.owner;
405 Repo {
406 created_at: l.added_at,
407 description: l.description,
408 knot: l.knot,
409 labels: None,
410 name: l.name,
411 repo_did: None,
412 source: None,
413 spindle: None,
414 topics: None,
415 website: None,
416 extra_data: l.extra_data,
417 }
418}
419
420fn upgrade_knot_member(l: LegacyKnotMember<DefaultStr>) -> KnotMember<DefaultStr> {
421 KnotMember {
422 created_at: l.added_at,
423 domain: l.domain,
424 subject: l.member,
425 extra_data: l.extra_data,
426 }
427}
428
429async fn upgrade_collaborator(
430 l: LegacyCollaborator<DefaultStr>,
431 resolver: &RepoIdResolver,
432) -> Option<Collaborator<DefaultStr>> {
433 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
434 Some(Collaborator {
435 created_at: l.created_at,
436 repo,
437 subject: l.subject,
438 extra_data: l.extra_data,
439 })
440}
441
442fn upgrade_ref_update(l: LegacyRefUpdate<DefaultStr>) -> RefUpdate<DefaultStr> {
443 RefUpdate {
444 changed_files: None,
445 committer_did: l.committer_did,
446 meta: l.meta,
447 new_sha: l.new_sha,
448 old_sha: l.old_sha,
449 owner_did: l.owner_did,
450 push_options: None,
451 r#ref: l.r#ref,
452 repo: l.repo_did,
453 changed_files: None,
454 push_options: None,
455 extra_data: l.extra_data,
456 }
457}
458
459async fn upgrade_star(
460 l: LegacyStar<DefaultStr>,
461 resolver: &RepoIdResolver,
462) -> Option<Star<DefaultStr>> {
463 let subject = if let Some(did) = l.subject_did {
464 StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
465 did,
466 extra_data: None,
467 }))
468 } else {
469 let uri = l.subject?;
470 let resolved = if is_repo_at_uri(&uri) {
471 cached_repo_did(resolver, &uri).await
472 } else {
473 None
474 };
475 match resolved {
476 Some(did) => StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
477 did,
478 extra_data: None,
479 })),
480 None => StarSubject::String(alloc::boxed::Box::new(StarString {
481 uri,
482 extra_data: None,
483 })),
484 }
485 };
486 Some(Star {
487 created_at: l.created_at,
488 subject,
489 extra_data: l.extra_data,
490 })
491}
492
493async fn cached_repo_did(
494 resolver: &RepoIdResolver,
495 uri: &jacquard_common::types::string::AtUri<DefaultStr>,
496) -> Option<Did<DefaultStr>> {
497 let owner = match uri.authority() {
498 AtIdentifier::Did(d) => d.clone().into_static(),
499 AtIdentifier::Handle(_) => return None,
500 };
501 let rkey: Rkey<DefaultStr> = uri.rkey()?.clone().into_static();
502 match resolver.cached_resolution(&owner, &rkey).await? {
503 Resolution::Mapped(did) => Some(did),
504 Resolution::NoRepoDid | Resolution::Unresolvable => None,
505 }
506}
507
508extern crate alloc;
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::RepoIdResolver;
514 use bobbin_runtime::RuntimeHasher;
515 use bobbin_types::edges::Record;
516 use jacquard_common::DefaultStr;
517 use jacquard_common::types::did::Did;
518 use jacquard_common::types::recordkey::Rkey;
519
520 fn did(s: &str) -> Did<DefaultStr> {
521 Did::new_owned(s).unwrap()
522 }
523
524 fn rkey(s: &str) -> Rkey<DefaultStr> {
525 Rkey::new_owned(s).unwrap()
526 }
527
528 fn nsid(s: &'static str) -> Nsid<DefaultStr> {
529 Nsid::new_static(s).unwrap()
530 }
531
532 #[test]
533 fn legacy_decode_routes_through_try_decode_for_known_nsids() {
534 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
535 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
536 .expect("legacy issue must decode");
537 assert!(matches!(
538 decoded,
539 DecodedRecord::Legacy(LegacyRecord::Issue(_))
540 ));
541 }
542
543 #[test]
544 fn canon_decode_wins_when_wire_matches_new_shape() {
545 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
546 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
547 .expect("canon issue must decode");
548 match decoded {
549 DecodedRecord::Canon(Record::Issue(i)) => {
550 assert_eq!(i.repo.as_ref(), "did:plc:squid")
551 }
552 other => panic!("expected canon issue, got {other:?}"),
553 }
554 }
555
556 #[test]
557 fn scrub_returns_none_for_unknown_nsid() {
558 let json = br#"{"$type":"sh.tangled.repo.issue","preferredHandle":""}"#;
559 assert!(scrub_record_bytes(&nsid("sh.tangled.repo.issue"), json).is_none());
560 }
561
562 #[test]
563 fn scrub_returns_none_when_target_field_is_non_empty() {
564 let json =
565 br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"nel.pet"}"#;
566 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
567 }
568
569 #[test]
570 fn scrub_returns_none_when_target_field_is_absent() {
571 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true}"#;
572 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
573 }
574
575 #[test]
576 fn scrub_returns_none_for_non_string_value() {
577 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":42}"#;
578 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
579 }
580
581 #[test]
582 fn scrub_returns_none_for_non_object_json() {
583 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"[]").is_none());
584 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"null").is_none());
585 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"123").is_none());
586 }
587
588 #[test]
589 fn scrub_returns_none_for_invalid_json() {
590 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"{not json").is_none());
591 }
592
593 #[test]
594 fn scrub_drops_empty_preferred_handle_and_preserves_other_fields() {
595 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
596 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json)
597 .expect("empty preferredHandle must trigger scrub");
598 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
599 let obj = value.as_object().expect("object");
600 assert!(!obj.contains_key("preferredHandle"));
601 assert_eq!(obj.get("bluesky"), Some(&serde_json::json!(true)));
602 assert_eq!(obj.get("description"), Some(&serde_json::json!("hi")));
603 assert_eq!(
604 obj.get("$type"),
605 Some(&serde_json::json!("sh.tangled.actor.profile"))
606 );
607 }
608
609 #[test]
610 fn scrub_replaces_null_arrays_with_empty_for_label_op() {
611 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
612 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.label.op"), json)
613 .expect("null delete must trigger scrub");
614 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
615 let obj = value.as_object().expect("object");
616 assert_eq!(obj.get("delete"), Some(&serde_json::json!([])));
617 assert!(obj.get("add").is_some_and(|v| v.is_array()));
618 }
619
620 #[test]
621 fn scrub_passes_through_when_label_op_arrays_are_non_null() {
622 let json = br#"{"$type":"sh.tangled.label.op","add":[],"delete":[],"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
623 assert!(scrub_record_bytes(&nsid("sh.tangled.label.op"), json).is_none());
624 }
625
626 #[test]
627 fn try_decode_recovers_label_op_with_null_delete() {
628 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
629 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.label.op"), json)
630 .expect("label.op with null delete must scrub-recover");
631 assert!(matches!(decoded, DecodedRecord::Canon(Record::LabelOp(_))));
632 }
633
634 #[test]
635 fn try_decode_recovers_profile_with_empty_preferred_handle() {
636 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
637 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.actor.profile"), json)
638 .expect("profile with empty preferredHandle must scrub-recover");
639 match decoded {
640 DecodedRecord::Canon(Record::Profile(p)) => {
641 assert!(p.preferred_handle.is_none());
642 assert_eq!(p.description.as_deref(), Some("hi"));
643 }
644 other => panic!("expected canon profile, got {other:?}"),
645 }
646 }
647
648 #[test]
649 fn legacy_decode_passes_through_for_unaffected_nsids() {
650 let json = br#"{"$type":"sh.tangled.graph.follow","subject":"did:plc:bailey","createdAt":"2026-05-01T00:00:00Z"}"#;
651 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.graph.follow"), json)
652 .expect("follow has no legacy form, must decode canon");
653 assert!(matches!(decoded, DecodedRecord::Canon(Record::Follow(_))));
654 }
655
656 #[test]
657 fn normalize_returns_none_for_invalid_json() {
658 assert!(normalize_record_fields(b"{not json").is_none());
659 }
660
661 #[test]
662 fn normalize_drops_repeated_dollar_type_key() {
663 let json =
664 br#"{"$type":"sh.tangled.repo.pull","title":"t","$type":"sh.tangled.repo.pull"}"#;
665 let normalized =
666 normalize_record_fields(json).expect("duplicate key must produce normalized output");
667 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
668 let obj = value.as_object().expect("object");
669 assert_eq!(obj.len(), 2);
670 assert_eq!(
671 obj.get("$type"),
672 Some(&serde_json::json!("sh.tangled.repo.pull"))
673 );
674 assert_eq!(obj.get("title"), Some(&serde_json::json!("t")));
675 }
676
677 #[test]
678 fn normalize_keeps_last_value_for_repeated_keys() {
679 let json = br#"{"$type":"sh.tangled.repo.issue","$type":"sh.tangled.repo.pull"}"#;
680 let normalized =
681 normalize_record_fields(json).expect("duplicate key must produce normalized output");
682 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
683 assert_eq!(
684 value.get("$type"),
685 Some(&serde_json::json!("sh.tangled.repo.pull")),
686 );
687 }
688
689 #[test]
690 fn synthesize_fills_empty_created_at_with_fallback() {
691 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow","createdAt":""}"#;
692 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
693 .expect("empty createdAt must be filled");
694 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
695 assert_eq!(
696 value.get("createdAt"),
697 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
698 );
699 }
700
701 #[test]
702 fn synthesize_fills_missing_created_at_with_fallback() {
703 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow"}"#;
704 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
705 .expect("missing createdAt must be filled");
706 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
707 assert_eq!(
708 value.get("createdAt"),
709 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
710 );
711 }
712
713 #[test]
714 fn synthesize_returns_none_when_created_at_already_set() {
715 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":"2026-05-01T00:00:00Z"}"#;
716 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
717 }
718
719 #[test]
720 fn synthesize_returns_none_for_non_string_created_at() {
721 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":null}"#;
722 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
723 }
724
725 #[tokio::test]
726 async fn try_decode_recovers_legacy_issue_after_synthesized_created_at() {
727 let json = br#"{"$type":"sh.tangled.repo.issue","body":"a bug","createdAt":"","repo":"at://did:plc:scallop/sh.tangled.repo/limpet","repoDid":"did:plc:scallop","title":"a bug"}"#;
728 let patched = synthesize_created_at(json, "2025-08-01T12:00:00.000000Z")
729 .expect("empty createdAt must be filled");
730 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), &patched)
731 .expect("issue must legacy-decode after createdAt fill");
732 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
733 let canon = match decoded {
734 DecodedRecord::Canon(r) => r,
735 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
736 };
737 match canon {
738 Record::Issue(i) => {
739 assert_eq!(AsRef::<str>::as_ref(&i.title), "a bug");
740 assert_eq!(i.repo.as_ref(), "did:plc:scallop");
741 }
742 other => panic!("expected issue, got {other:?}"),
743 }
744 }
745
746 #[tokio::test]
747 async fn try_decode_recovers_canon_pull_with_duplicate_dollar_type() {
748 let json = br#"{"$type":"sh.tangled.repo.pull","createdAt":"2026-05-01T00:00:00Z","title":"meow","target":{"branch":"main","repo":"at://did:plc:scallop/sh.tangled.repo/limpet"},"rounds":[],"$type":"sh.tangled.repo.pull"}"#;
749 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.pull"), json)
750 .expect("duplicate $type pull must normalize-recover");
751 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
752 resolver
753 .observe(
754 did("did:plc:scallop"),
755 rkey("limpet"),
756 Some(did("did:plc:scallop")),
757 )
758 .await;
759 let canon = match decoded {
760 DecodedRecord::Canon(r) => r,
761 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
762 };
763 match canon {
764 Record::Pull(p) => assert_eq!(AsRef::<str>::as_ref(&p.title), "meow"),
765 other => panic!("expected pull, got {other:?}"),
766 }
767 }
768
769 #[tokio::test]
770 async fn upgrade_issue_uses_repo_did_directly() {
771 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
772 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:scallop","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
773 let legacy =
774 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
775 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
776 match canon {
777 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
778 other => panic!("expected canon issue, got {other:?}"),
779 }
780 }
781
782 #[tokio::test]
783 async fn upgrade_issue_resolves_repo_uri_via_observed_resolver() {
784 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
785 let owner = did("did:plc:nel");
786 let key = rkey("abcabcabcabcz");
787 resolver
788 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
789 .await;
790 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
791 let legacy =
792 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
793 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
794 match canon {
795 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
796 other => panic!("expected canon issue, got {other:?}"),
797 }
798 }
799
800 #[tokio::test]
801 async fn upgrade_issue_drops_when_resolver_cannot_map() {
802 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
803 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
804 let legacy =
805 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
806 assert!(
807 upgrade(legacy, &resolver).await.is_none(),
808 "no resolver entry and no repoDid means the canon Did cannot be constructed",
809 );
810 }
811
812 #[tokio::test]
813 async fn upgrade_pull_propagates_target_resolution() {
814 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
815 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
816 let legacy =
817 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
818 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
819 match canon {
820 Record::Pull(p) => {
821 assert_eq!(p.target.repo, did("did:plc:scallop"));
822 assert!(p.source.is_none());
823 }
824 other => panic!("expected canon pull, got {other:?}"),
825 }
826 }
827
828 #[tokio::test]
829 async fn upgrade_pull_pre_rounds_synthesizes_round_from_top_level_patch_blob() {
830 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
831 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"},"patchBlob":{"$type":"blob","mimeType":"application/gzip","ref":{"$link":"bafkreibpatvbeajtwzlr4jwr4s2hnwo5l7sgdbfnqu6n7ctd2bcbtluw4a"},"size":920}}"#;
832 let legacy =
833 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
834 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
835 match canon {
836 Record::Pull(p) => {
837 assert_eq!(
838 p.rounds.len(),
839 1,
840 "pre-rounds wire must yield exactly one synthesized round"
841 );
842 assert_eq!(
843 p.rounds[0].patch_blob.blob().mime_type.as_ref(),
844 "application/gzip"
845 );
846 assert_eq!(p.rounds[0].created_at, p.created_at);
847 }
848 other => panic!("expected canon pull, got {other:?}"),
849 }
850 }
851
852 #[tokio::test]
853 async fn upgrade_pull_omits_round_when_neither_rounds_nor_patch_blob_present() {
854 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
855 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
856 let legacy =
857 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
858 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
859 match canon {
860 Record::Pull(p) => assert!(p.rounds.is_empty()),
861 other => panic!("expected canon pull, got {other:?}"),
862 }
863 }
864
865 #[tokio::test]
866 async fn upgrade_public_key_renames_created_to_created_at() {
867 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
868 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"ssh-ed25519 AAAA","name":"laptop"}"#;
869 let legacy =
870 LegacyRecord::from_json_bytes(&nsid("sh.tangled.publicKey"), json).expect("decode");
871 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
872 match canon {
873 Record::PublicKey(k) => {
874 assert_eq!(k.created_at.as_str(), "2025-04-15T18:35:38Z");
875 assert_eq!(k.key.as_str(), "ssh-ed25519 AAAA");
876 assert_eq!(k.name.as_str(), "laptop");
877 }
878 other => panic!("expected canon publicKey, got {other:?}"),
879 }
880 }
881
882 #[tokio::test]
883 async fn upgrade_repo_renames_added_at_and_drops_owner() {
884 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
885 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","description":"hi","knot":"knot1.tangled.sh","name":"site","owner":"did:plc:nel"}"#;
886 let legacy = LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo"), json).expect("decode");
887 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
888 match canon {
889 Record::Repo(r) => {
890 assert_eq!(r.created_at.as_str(), "2025-03-21T10:18:58Z");
891 assert_eq!(r.description.as_deref(), Some("hi"));
892 assert_eq!(r.knot.as_str(), "knot1.tangled.sh");
893 assert_eq!(r.name.as_deref(), Some("site"));
894 assert!(r.repo_did.is_none(), "legacy repos have no repo_did");
895 }
896 other => panic!("expected canon repo, got {other:?}"),
897 }
898 }
899
900 #[tokio::test]
901 async fn upgrade_knot_member_renames_added_at_and_member() {
902 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
903 let json = br#"{"$type":"sh.tangled.knot.member","addedAt":"2025-03-31T05:14:09Z","domain":"knot.example","member":"did:plc:nel"}"#;
904 let legacy =
905 LegacyRecord::from_json_bytes(&nsid("sh.tangled.knot.member"), json).expect("decode");
906 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
907 match canon {
908 Record::KnotMember(m) => {
909 assert_eq!(m.created_at.as_str(), "2025-03-31T05:14:09Z");
910 assert_eq!(m.domain.as_str(), "knot.example");
911 assert_eq!(m.subject, did("did:plc:nel"));
912 }
913 other => panic!("expected canon knot.member, got {other:?}"),
914 }
915 }
916
917 #[tokio::test]
918 async fn legacy_pull_target_with_empty_repo_did_treats_as_none() {
919 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
920 resolver
921 .observe(
922 did("did:plc:nel"),
923 rkey("abcabcabcabcz"),
924 Some(did("did:plc:scallop")),
925 )
926 .await;
927 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","repoDid":""}}"#;
928 let legacy =
929 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
930 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
931 match canon {
932 Record::Pull(p) => assert_eq!(p.target.repo, did("did:plc:scallop")),
933 other => panic!("expected canon pull, got {other:?}"),
934 }
935 }
936
937 #[tokio::test]
938 async fn try_decode_recovers_publickey_with_created_field() {
939 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"k","name":"n"}"#;
940 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.publicKey"), json)
941 .expect("legacy publicKey must decode");
942 assert!(matches!(
943 decoded,
944 DecodedRecord::Legacy(LegacyRecord::PublicKey(_))
945 ));
946 }
947
948 #[tokio::test]
949 async fn try_decode_recovers_repo_with_added_at_field() {
950 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","knot":"knot1.tangled.sh","owner":"did:plc:nel"}"#;
951 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo"), json)
952 .expect("legacy repo must decode");
953 assert!(matches!(
954 decoded,
955 DecodedRecord::Legacy(LegacyRecord::Repo(_))
956 ));
957 }
958
959 #[tokio::test]
960 async fn upgrade_pull_source_repo_resolution_is_independent_of_target() {
961 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
962 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"},"source":{"branch":"feat","repo":"at://did:plc:nel/sh.tangled.repo/missing"}}"#;
963 let legacy =
964 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
965 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
966 match canon {
967 Record::Pull(p) => {
968 assert_eq!(p.target.repo, did("did:plc:scallop"));
969 let source = p.source.expect("source struct retained");
970 assert_eq!(source.branch.as_str(), "feat");
971 assert!(
972 source.repo.is_none(),
973 "unresolvable source repo at-uri leaves the source.repo None rather than dropping the whole pull",
974 );
975 }
976 other => panic!("expected canon pull, got {other:?}"),
977 }
978 }
979
980 #[tokio::test]
981 async fn upgrade_ref_update_renames_repo_did_to_repo() {
982 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
983 let json = br#"{"$type":"sh.tangled.git.refUpdate","ref":"refs/heads/main","committerDid":"did:plc:olaren","repoDid":"did:plc:scallop","oldSha":"0000000000000000000000000000000000000000","newSha":"1111111111111111111111111111111111111111","meta":{"isDefaultRef":true,"commitCount":{}}}"#;
984 let legacy =
985 LegacyRecord::from_json_bytes(&nsid("sh.tangled.git.refUpdate"), json).expect("decode");
986 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
987 match canon {
988 Record::RefUpdate(r) => {
989 assert_eq!(r.repo, did("did:plc:scallop"));
990 assert!(r.changed_files.is_none());
991 assert!(r.push_options.is_none());
992 }
993 other => panic!("expected canon ref update, got {other:?}"),
994 }
995 }
996
997 #[tokio::test]
998 async fn upgrade_star_prefers_subject_did_over_subject_uri() {
999 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1000 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.string/k1","subjectDid":"did:plc:scallop"}"#;
1001 let legacy =
1002 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1003 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1004 match canon {
1005 Record::Star(s) => match s.subject {
1006 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
1007 StarSubject::String(_) => panic!("subjectDid must win"),
1008 },
1009 other => panic!("expected canon star, got {other:?}"),
1010 }
1011 }
1012
1013 #[tokio::test]
1014 async fn upgrade_star_falls_back_to_string_when_repo_uri_not_in_cache() {
1015 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1016 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1017 let legacy =
1018 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1019 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1020 match canon {
1021 Record::Star(s) => match s.subject {
1022 StarSubject::String(s) => assert_eq!(
1023 s.uri.as_ref(),
1024 "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz",
1025 "cache-miss on repo uri preserves the uri under the #string variant for later normalization",
1026 ),
1027 StarSubject::Repo(_) => panic!("cold cache must not upgrade to Repo variant"),
1028 },
1029 other => panic!("expected canon star, got {other:?}"),
1030 }
1031 }
1032
1033 #[tokio::test]
1034 async fn upgrade_star_uses_cached_repo_did_when_observed() {
1035 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1036 let owner = did("did:plc:nel");
1037 let key = rkey("abcabcabcabcz");
1038 resolver
1039 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
1040 .await;
1041 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1042 let legacy =
1043 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1044 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1045 match canon {
1046 Record::Star(s) => match s.subject {
1047 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
1048 StarSubject::String(_) => panic!("observed cache must upgrade to Repo variant"),
1049 },
1050 other => panic!("expected canon star, got {other:?}"),
1051 }
1052 }
1053
1054 #[tokio::test]
1055 async fn upgrade_collaborator_requires_repo_did() {
1056 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1057 let with_did = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repoDid":"did:plc:scallop"}"#;
1058 let canon = upgrade(
1059 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), with_did)
1060 .expect("decode"),
1061 &resolver,
1062 )
1063 .await
1064 .expect("upgrade");
1065 match canon {
1066 Record::Collaborator(c) => assert_eq!(c.repo, did("did:plc:scallop")),
1067 other => panic!("expected canon collaborator, got {other:?}"),
1068 }
1069
1070 let no_resolution = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1071 let legacy =
1072 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), no_resolution)
1073 .expect("decode");
1074 assert!(upgrade(legacy, &resolver).await.is_none());
1075 }
1076}