1use crate::{ContentHash, Identity, RecordKey, Revision};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub enum RecordKind {
10 MemoryTier,
11 Topic,
12 Session,
13 Checkpoint,
14 Ticket,
16 TicketEvent,
18 GraphManifest,
21 VectorManifest,
27 Person,
30 IdentityBinding,
33 RoleGrant,
36 ChannelConfig,
39 LinkToken,
42 AuditEntry,
45 Tombstone,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum MergeClass {
54 AppendOnly,
56 Structured,
58 Opaque,
60 Derived,
66}
67
68impl RecordKind {
69 pub fn merge_class(self) -> MergeClass {
70 match self {
71 RecordKind::Topic | RecordKind::Session | RecordKind::TicketEvent => {
72 MergeClass::AppendOnly
73 }
74 RecordKind::MemoryTier
75 | RecordKind::Ticket
76 | RecordKind::Person
77 | RecordKind::IdentityBinding
78 | RecordKind::RoleGrant
79 | RecordKind::ChannelConfig => MergeClass::Structured,
80 RecordKind::Checkpoint => MergeClass::Opaque,
81 RecordKind::LinkToken | RecordKind::AuditEntry => MergeClass::Opaque,
84 RecordKind::GraphManifest => MergeClass::Derived,
85 RecordKind::VectorManifest => MergeClass::Opaque,
88 RecordKind::Tombstone => MergeClass::Opaque,
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub enum Body {
103 Inline(Vec<u8>),
104 Blob {
110 hash: ContentHash,
111 len: u64,
112 },
113}
114
115impl Body {
116 pub fn blob(content: &[u8]) -> Self {
119 Body::Blob {
120 hash: ContentHash::of(content),
121 len: content.len() as u64,
122 }
123 }
124
125 pub fn bytes(&self) -> &[u8] {
130 match self {
131 Body::Inline(b) => b,
132 Body::Blob { hash, .. } => hash.0.as_bytes(),
133 }
134 }
135}
136
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Meta {
140 pub author: Identity,
141 pub origin_system: String,
142 pub created: i64,
147 pub updated: i64,
152 pub labels: BTreeMap<String, String>,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Record {
158 pub key: RecordKey,
159 pub kind: RecordKind,
160 pub revision: Revision,
161 pub parent: Option<Revision>,
162 pub body: Body,
163 pub meta: Meta,
164 pub links: Vec<RecordKey>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub ancestors: Vec<Revision>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub deleted_at: Option<i64>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub deleted_blob: Option<ContentHash>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub deleted_kind: Option<RecordKind>,
196}
197
198impl Meta {
199 pub fn new(author: Identity, origin_system: impl Into<String>) -> Self {
205 Self {
206 author,
207 origin_system: origin_system.into(),
208 created: 0,
209 updated: 0,
210 labels: BTreeMap::new(),
211 }
212 }
213}
214
215impl Record {
216 pub fn create(key: RecordKey, kind: RecordKind, body: Body, meta: Meta) -> Self {
235 Self {
236 revision: Revision::initial(body.bytes()),
237 key,
238 kind,
239 parent: None,
240 body,
241 meta,
242 links: Vec::new(),
243 ancestors: Vec::new(),
244 deleted_at: None,
245 deleted_blob: None,
246 deleted_kind: None,
247 }
248 }
249
250 pub fn update(&self, body: Body, meta: Meta) -> Self {
261 Self {
262 key: self.key.clone(),
263 kind: self.kind,
264 revision: self.revision.next(body.bytes()),
265 parent: Some(self.revision.clone()),
266 body,
267 meta,
268 links: self.links.clone(),
269 ancestors: Vec::new(),
270 deleted_at: None,
271 deleted_blob: None,
272 deleted_kind: None,
273 }
274 }
275
276 pub fn is_tombstone(&self) -> bool {
278 self.kind == RecordKind::Tombstone
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn meta_by(author: &str) -> Meta {
289 Meta::new(Identity::new(author), "test")
290 }
291
292 #[test]
293 fn meta_new_carries_author_and_origin_and_nothing_else() {
294 let m = meta_by("ada");
295 assert_eq!(m.author, Identity::new("ada"));
296 assert_eq!(m.origin_system, "test");
297 assert_eq!((m.created, m.updated), (0, 0));
298 assert!(m.labels.is_empty());
299 }
300
301 #[test]
302 fn create_is_a_first_revision_with_no_history() {
303 let key = RecordKey::new("ns", "col", "id");
304 let body = Body::Inline(b"hello".to_vec());
305 let r = Record::create(key.clone(), RecordKind::Topic, body.clone(), meta_by("ada"));
306
307 assert_eq!(r.key, key);
308 assert_eq!(r.kind, RecordKind::Topic);
309 assert_eq!(r.revision, Revision::initial(b"hello"));
310 assert_eq!(r.parent, None);
311 assert_eq!(r.body, body);
312 assert!(r.links.is_empty() && r.ancestors.is_empty());
313 assert_eq!(r.deleted_at, None);
314 }
315
316 #[test]
317 fn update_follows_the_revision_it_was_built_from() {
318 let key = RecordKey::new("ns", "col", "id");
319 let mut v0 = Record::create(
320 key.clone(),
321 RecordKind::Topic,
322 Body::Inline(b"v0".to_vec()),
323 meta_by("ada"),
324 );
325 v0.links = vec![RecordKey::new("ns", "col", "other")];
326
327 let v1 = v0.update(Body::Inline(b"v1".to_vec()), meta_by("grace"));
328
329 assert_eq!(v1.revision, v0.revision.next(b"v1"));
330 assert_eq!(v1.parent, Some(v0.revision.clone()));
331 assert_eq!(v1.body, Body::Inline(b"v1".to_vec()));
332 assert_eq!(
333 v1.meta.author,
334 Identity::new("grace"),
335 "the updater writes it"
336 );
337 assert_eq!((v1.key, v1.kind), (key, RecordKind::Topic));
338 assert_eq!(v1.links, v0.links, "links carry over");
339 assert!(
340 v1.ancestors.is_empty(),
341 "the store folds history in on commit"
342 );
343 }
344
345 #[tokio::test]
346 async fn built_records_commit_through_occ() {
347 use crate::{PutResult, Store, memstore::MemStore};
348
349 let store = MemStore::new();
350 let key = RecordKey::new("ns", "col", "id");
351 let v0 = Record::create(
352 key.clone(),
353 RecordKind::Topic,
354 Body::Inline(b"v0".to_vec()),
355 meta_by("ada"),
356 );
357 assert!(matches!(
358 store.put(v0.clone(), None).await.unwrap(),
359 PutResult::Committed(_)
360 ));
361
362 let current = store.get(&key).await.unwrap().unwrap();
364 let v1 = current.update(Body::Inline(b"v1".to_vec()), meta_by("ada"));
365 assert!(matches!(
366 store.put(v1, Some(current.revision.clone())).await.unwrap(),
367 PutResult::Committed(_)
368 ));
369
370 let stale = current.update(Body::Inline(b"v1b".to_vec()), meta_by("ada"));
372 assert!(matches!(
373 store
374 .put(stale, Some(current.revision.clone()))
375 .await
376 .unwrap(),
377 PutResult::Conflict(_)
378 ));
379 }
380
381 #[test]
382 fn merge_class_is_assigned_per_kind() {
383 assert_eq!(RecordKind::Topic.merge_class(), MergeClass::AppendOnly);
384 assert_eq!(RecordKind::Session.merge_class(), MergeClass::AppendOnly);
385 assert_eq!(RecordKind::MemoryTier.merge_class(), MergeClass::Structured);
386 assert_eq!(RecordKind::Checkpoint.merge_class(), MergeClass::Opaque);
387 assert_eq!(RecordKind::Ticket.merge_class(), MergeClass::Structured);
388 assert_eq!(
389 RecordKind::TicketEvent.merge_class(),
390 MergeClass::AppendOnly
391 );
392 assert_eq!(RecordKind::GraphManifest.merge_class(), MergeClass::Derived);
393 assert_eq!(RecordKind::Person.merge_class(), MergeClass::Structured);
394 assert_eq!(
395 RecordKind::IdentityBinding.merge_class(),
396 MergeClass::Structured
397 );
398 assert_eq!(RecordKind::RoleGrant.merge_class(), MergeClass::Structured);
399 assert_eq!(
400 RecordKind::ChannelConfig.merge_class(),
401 MergeClass::Structured
402 );
403 assert_eq!(RecordKind::LinkToken.merge_class(), MergeClass::Opaque);
404 assert_eq!(RecordKind::AuditEntry.merge_class(), MergeClass::Opaque);
405 }
406
407 #[test]
408 fn fleet_kinds_serialize_as_their_names() {
409 for (kind, name) in [
410 (RecordKind::Person, "\"Person\""),
411 (RecordKind::IdentityBinding, "\"IdentityBinding\""),
412 (RecordKind::RoleGrant, "\"RoleGrant\""),
413 (RecordKind::ChannelConfig, "\"ChannelConfig\""),
414 (RecordKind::LinkToken, "\"LinkToken\""),
415 (RecordKind::AuditEntry, "\"AuditEntry\""),
416 ] {
417 assert_eq!(serde_json::to_string(&kind).unwrap(), name);
418 let back: RecordKind = serde_json::from_str(name).unwrap();
419 assert_eq!(back, kind);
420 }
421 }
422
423 #[test]
424 fn body_exposes_bytes() {
425 assert_eq!(Body::Inline(b"hi".to_vec()).bytes(), b"hi");
426 }
427
428 #[test]
429 fn blob_body_references_content_by_hash() {
430 let body = Body::blob(b"fn main() {}");
431 match &body {
432 Body::Blob { hash, len } => {
433 assert_eq!(*hash, crate::ContentHash::of(b"fn main() {}"));
434 assert_eq!(*len, 12);
435 }
436 _ => panic!("expected Body::Blob"),
437 }
438 }
439
440 #[test]
441 fn blob_body_bytes_are_stable_per_content() {
442 assert_eq!(Body::blob(b"same").bytes(), Body::blob(b"same").bytes());
445 assert_ne!(Body::blob(b"same").bytes(), Body::blob(b"diff").bytes());
446 }
447
448 fn plain_record() -> Record {
449 let body = Body::Inline(b"hello".to_vec());
450 Record {
451 key: RecordKey::new("ns", "col", "id"),
452 kind: RecordKind::Topic,
453 revision: Revision::initial(body.bytes()),
454 parent: None,
455 body,
456 meta: Meta {
457 author: Identity::new("t"),
458 origin_system: "test".into(),
459 created: 0,
460 updated: 0,
461 labels: BTreeMap::new(),
462 },
463 links: Vec::new(),
464 ancestors: Vec::new(),
465 deleted_at: None,
466 deleted_blob: None,
467 deleted_kind: None,
468 }
469 }
470
471 #[test]
472 fn tombstone_kind_is_opaque_and_detected() {
473 assert_eq!(RecordKind::Tombstone.merge_class(), MergeClass::Opaque);
474 let mut r = plain_record();
475 assert!(!r.is_tombstone());
476 r.kind = RecordKind::Tombstone;
477 assert!(r.is_tombstone());
478 }
479
480 #[test]
481 fn tombstone_kind_serializes_as_its_name() {
482 assert_eq!(
483 serde_json::to_string(&RecordKind::Tombstone).unwrap(),
484 "\"Tombstone\""
485 );
486 }
487
488 #[test]
489 fn empty_new_fields_are_omitted_from_json() {
490 let json = serde_json::to_value(plain_record()).unwrap();
491 let obj = json.as_object().unwrap();
492 assert!(!obj.contains_key("ancestors"));
493 assert!(!obj.contains_key("deleted_at"));
494 }
495
496 #[test]
497 fn legacy_json_without_new_fields_deserializes() {
498 let mut json = serde_json::to_value(plain_record()).unwrap();
499 let obj = json.as_object_mut().unwrap();
500 obj.remove("ancestors");
501 obj.remove("deleted_at");
502 let back: Record = serde_json::from_value(json).unwrap();
503 assert_eq!(back, plain_record());
504 }
505
506 #[test]
507 fn populated_new_fields_roundtrip() {
508 let mut r = plain_record();
509 r.kind = RecordKind::Tombstone;
510 r.ancestors = vec![Revision::initial(b"a"), Revision::initial(b"b")];
511 r.deleted_at = Some(1_700_000_000_000);
512 let back: Record = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
513 assert_eq!(back, r);
514 }
515}