heddle_object_model/object/collaboration/
ids.rs1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use uuid::Uuid;
7
8use crate::object::{ContentHash, StateAttachmentId, StateId};
9
10#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
11pub struct CollabOpId([u8; 32]);
12
13impl CollabOpId {
14 pub fn for_bytes(bytes: &[u8]) -> Self {
15 Self(*ContentHash::compute_typed("collaboration-operation", bytes).as_bytes())
16 }
17
18 pub fn from_bytes(bytes: [u8; 32]) -> Self {
19 Self(bytes)
20 }
21
22 pub fn as_bytes(&self) -> &[u8; 32] {
23 &self.0
24 }
25
26 pub fn to_hex(&self) -> String {
27 hex::encode(self.0)
28 }
29
30 pub fn to_string_full(&self) -> String {
31 format!(
32 "co-{}",
33 base32::encode(base32::Alphabet::Crockford, &self.0).to_lowercase()
34 )
35 }
36
37 pub fn parse(value: &str) -> Result<Self, CollabOpIdParseError> {
38 let Some(value) = value.strip_prefix("co-") else {
39 return Err(CollabOpIdParseError::MissingPrefix);
40 };
41 let bytes = base32::decode(base32::Alphabet::Crockford, &value.to_uppercase())
42 .ok_or(CollabOpIdParseError::InvalidBase32)?;
43 let bytes: [u8; 32] = bytes
44 .try_into()
45 .map_err(|_| CollabOpIdParseError::InvalidLength)?;
46 Ok(Self(bytes))
47 }
48}
49
50impl fmt::Debug for CollabOpId {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(&self.to_string_full())
53 }
54}
55
56impl fmt::Display for CollabOpId {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(&self.to_string_full())
59 }
60}
61
62impl FromStr for CollabOpId {
63 type Err = CollabOpIdParseError;
64 fn from_str(value: &str) -> Result<Self, Self::Err> {
65 Self::parse(value)
66 }
67}
68
69impl Serialize for CollabOpId {
70 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71 where
72 S: Serializer,
73 {
74 serializer.serialize_str(&self.to_string_full())
75 }
76}
77
78impl<'de> Deserialize<'de> for CollabOpId {
79 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80 where
81 D: Deserializer<'de>,
82 {
83 String::deserialize(deserializer)?
84 .parse()
85 .map_err(de::Error::custom)
86 }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
90pub enum CollabOpIdParseError {
91 #[error("collaboration operation id must start with co-")]
92 MissingPrefix,
93 #[error("invalid collaboration operation base32")]
94 InvalidBase32,
95 #[error("collaboration operation id must contain 32 bytes")]
96 InvalidLength,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)]
100pub struct DiscussionRecordId(Uuid);
101
102impl DiscussionRecordId {
103 pub fn generate() -> Self {
104 Self(Uuid::now_v7())
105 }
106
107 pub fn from_uuid(value: Uuid) -> Result<Self, DiscussionRecordIdParseError> {
108 if value.get_version_num() != 7 {
109 return Err(DiscussionRecordIdParseError::WrongVersion);
110 }
111 Ok(Self(value))
112 }
113
114 pub fn for_legacy_source(source: &LegacySourceLocator, opened_at_ms: i64) -> Self {
115 let hash = ContentHash::compute_typed("legacy-discussion", &source.identity_bytes());
116 let mut bytes = [0; 16];
117 bytes[..6].copy_from_slice(&(opened_at_ms.max(0) as u64).to_be_bytes()[2..]);
118 bytes[6..].copy_from_slice(&hash.as_bytes()[..10]);
119 bytes[6] = (bytes[6] & 0x0f) | 0x70;
120 bytes[8] = (bytes[8] & 0x3f) | 0x80;
121 Self(Uuid::from_bytes(bytes))
122 }
123
124 pub fn for_hosted_source(hosted_id: &str) -> Self {
130 let hash = ContentHash::compute_typed("hosted-discussion", hosted_id.as_bytes());
131 let mut bytes = [0; 16];
132 bytes.copy_from_slice(&hash.as_bytes()[..16]);
133 bytes[6] = (bytes[6] & 0x0f) | 0x70;
134 bytes[8] = (bytes[8] & 0x3f) | 0x80;
135 Self(Uuid::from_bytes(bytes))
136 }
137}
138
139impl fmt::Display for DiscussionRecordId {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "disc-{}", self.0)
142 }
143}
144
145impl FromStr for DiscussionRecordId {
146 type Err = DiscussionRecordIdParseError;
147 fn from_str(value: &str) -> Result<Self, Self::Err> {
148 let value = value
149 .strip_prefix("disc-")
150 .ok_or(DiscussionRecordIdParseError::MissingPrefix)?;
151 Self::from_uuid(Uuid::parse_str(value).map_err(DiscussionRecordIdParseError::InvalidUuid)?)
152 }
153}
154
155impl Serialize for DiscussionRecordId {
156 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157 where
158 S: Serializer,
159 {
160 serializer.serialize_str(&self.to_string())
161 }
162}
163
164impl<'de> Deserialize<'de> for DiscussionRecordId {
165 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166 where
167 D: Deserializer<'de>,
168 {
169 String::deserialize(deserializer)?
170 .parse()
171 .map_err(de::Error::custom)
172 }
173}
174
175#[derive(Debug, thiserror::Error)]
176pub enum DiscussionRecordIdParseError {
177 #[error("discussion id must start with disc-")]
178 MissingPrefix,
179 #[error("invalid discussion UUID: {0}")]
180 InvalidUuid(uuid::Error),
181 #[error("discussion id must be UUIDv7")]
182 WrongVersion,
183}
184
185macro_rules! nonempty_id {
186 ($name:ident, $message:literal) => {
187 #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)]
188 #[serde(transparent)]
189 pub struct $name(String);
190
191 impl $name {
192 pub fn new(value: impl Into<String>) -> Result<Self, String> {
193 let value = value.into();
194 if value.trim().is_empty() {
195 return Err($message.to_string());
196 }
197 Ok(Self(value))
198 }
199
200 pub fn as_str(&self) -> &str {
201 &self.0
202 }
203 }
204
205 impl<'de> Deserialize<'de> for $name {
206 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207 where
208 D: Deserializer<'de>,
209 {
210 Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
211 }
212 }
213 };
214}
215
216nonempty_id!(
217 CollaborationIdempotencyKey,
218 "idempotency key must not be empty"
219);
220nonempty_id!(LegacyDiscussionId, "legacy discussion id must not be empty");
221
222#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
223pub struct LegacySourceLocator {
224 pub state_id: StateId,
225 pub attachment_id: StateAttachmentId,
226 pub blob_hash: ContentHash,
227}
228
229impl LegacySourceLocator {
230 pub fn new(
231 state_id: StateId,
232 attachment_id: StateAttachmentId,
233 blob_hash: ContentHash,
234 ) -> Self {
235 Self {
236 state_id,
237 attachment_id,
238 blob_hash,
239 }
240 }
241
242 pub fn identity_bytes(&self) -> [u8; 96] {
243 let mut bytes = [0; 96];
244 bytes[..32].copy_from_slice(self.state_id.as_bytes());
245 bytes[32..64].copy_from_slice(self.attachment_id.as_hash().as_bytes());
246 bytes[64..].copy_from_slice(self.blob_hash.as_bytes());
247 bytes
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn serialized_nonempty_ids_cannot_bypass_validation() {
257 let bytes = rmp_serde::to_vec_named("").unwrap();
258 assert!(rmp_serde::from_slice::<CollaborationIdempotencyKey>(&bytes).is_err());
259 assert!(rmp_serde::from_slice::<LegacyDiscussionId>(&bytes).is_err());
260 }
261
262 #[test]
263 fn for_hosted_source_is_deterministic_and_valid_v7() {
264 let a = DiscussionRecordId::for_hosted_source("hc-abc123");
267 let b = DiscussionRecordId::for_hosted_source("hc-abc123");
268 assert_eq!(a, b, "same hosted id must derive the same local id");
269 assert_ne!(a, DiscussionRecordId::for_hosted_source("hc-xyz789"));
271 let text = a.to_string();
273 assert!(text.starts_with("disc-"));
274 assert_eq!(text.parse::<DiscussionRecordId>().unwrap(), a);
275 }
276
277 #[test]
278 fn legacy_locator_preserves_full_typed_identities() {
279 let locator = LegacySourceLocator::new(
280 StateId::from_bytes([1; 32]),
281 StateAttachmentId::from_hash(ContentHash::from_bytes([2; 32])),
282 ContentHash::from_bytes([3; 32]),
283 );
284 let bytes = locator.identity_bytes();
285 assert_eq!(&bytes[..32], &[1; 32]);
286 assert_eq!(&bytes[32..64], &[2; 32]);
287 assert_eq!(&bytes[64..], &[3; 32]);
288 }
289}