From 49fb8d442e08f1ebb03ebc9d992fa12dc0eacc8b Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 5 Aug 2026 09:31:10 +0300 Subject: [PATCH] bobbin: handle PR records without `versions` field Signed-off-by: Seongmin Lee --- bobbin/crates/resolver/src/legacy_upgrade.rs | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/bobbin/crates/resolver/src/legacy_upgrade.rs b/bobbin/crates/resolver/src/legacy_upgrade.rs index dd15b59e..948b9e74 100644 --- a/bobbin/crates/resolver/src/legacy_upgrade.rs +++ b/bobbin/crates/resolver/src/legacy_upgrade.rs @@ -95,6 +95,7 @@ pub fn synthesize_created_at(bytes: &[u8], fallback_rfc3339: &str) -> Option &'static [(&'static str, FieldRule)] { @@ -104,7 +105,10 @@ fn scrub_rules(nsid: &str) -> &'static [(&'static str, FieldRule)] { ("add", FieldRule::NullToEmptyArray), ("delete", FieldRule::NullToEmptyArray), ], - "sh.tangled.repo.pull" => &[("rounds", FieldRule::NullToEmptyArray)], + "sh.tangled.repo.pull" => &[ + ("rounds", FieldRule::NullToEmptyArray), + ("versions", FieldRule::EnsureArray), + ], _ => &[], } } @@ -126,6 +130,7 @@ pub fn scrub_record_bytes>( Some((*field, *rule)) } (FieldRule::NullToEmptyArray, Some(serde_json::Value::Null)) => Some((*field, *rule)), + (FieldRule::EnsureArray, Some(serde_json::Value::Null) | None) => Some((*field, *rule)), _ => None, }) .collect(); @@ -136,7 +141,7 @@ pub fn scrub_record_bytes>( FieldRule::DropIfEmptyString => { obj.remove(*field); } - FieldRule::NullToEmptyArray => { + FieldRule::NullToEmptyArray | FieldRule::EnsureArray => { obj.insert( (*field).to_owned(), serde_json::Value::Array(alloc::vec::Vec::new()), @@ -622,6 +627,21 @@ mod tests { assert!(scrub_record_bytes(&nsid("sh.tangled.label.op"), json).is_none()); } + #[test] + fn pull_decodes_with_only_rounds_or_only_versions() { + // rounds-only (pre-versions), versions-only (current) and an explicit null must + // all decode canon: the lexicon requires both arrays but no writer emits both. + for json in [ + br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repo":"did:plc:scallop"},"rounds":[]}"#.as_slice(), + br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repo":"did:plc:scallop"},"versions":[]}"#.as_slice(), + br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repo":"did:plc:scallop"},"rounds":[],"versions":null}"#.as_slice(), + ] { + let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.pull"), json) + .expect("partial pull must scrub-recover"); + assert!(matches!(decoded, DecodedRecord::Canon(Record::Pull(_)))); + } + } + #[test] fn try_decode_recovers_label_op_with_null_delete() { 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"}"#; -- 2.51.2