Skip to main content

gonzalo_core/
record.rs

1//! The universal persisted unit and its classification.
2
3use crate::{ContentHash, Identity, RecordKey, Revision};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// What a record represents. Drives the merge strategy.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub enum RecordKind {
10    MemoryTier,
11    Topic,
12    Session,
13    Checkpoint,
14    /// A tracked work item imported from an external ticket platform.
15    Ticket,
16    /// An append-only comment/event on a ticket.
17    TicketEvent,
18    /// A per-view code-graph manifest: `(repo, view_id) -> { path -> content_hash }`.
19    /// Regenerable from source; reconciled last-writer-wins. See ADR 0012.
20    GraphManifest,
21}
22
23/// How concurrent edits to a record of a given kind are reconciled.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum MergeClass {
26    /// Edits union/concatenate (auto-memory topics, session transcripts).
27    AppendOnly,
28    /// Field-level 3-way merge against the common base.
29    Structured,
30    /// No safe automatic merge; surface to the caller.
31    Opaque,
32    /// Regenerable / don't-merge (e.g. per-view code-graph manifests, ADR 0012).
33    /// The body can be re-derived from source, and views are single-writer, so a
34    /// divergence is rare and reconciled deterministically in favor of side A
35    /// (the `ours` argument to `merge`, which has no `Meta` to compare) rather
36    /// than a content merge — never a surfaced conflict.
37    Derived,
38}
39
40impl RecordKind {
41    pub fn merge_class(self) -> MergeClass {
42        match self {
43            RecordKind::Topic | RecordKind::Session | RecordKind::TicketEvent => {
44                MergeClass::AppendOnly
45            }
46            RecordKind::MemoryTier | RecordKind::Ticket => MergeClass::Structured,
47            RecordKind::Checkpoint => MergeClass::Opaque,
48            RecordKind::GraphManifest => MergeClass::Derived,
49        }
50    }
51}
52
53/// A record body. `Inline` stores bytes directly in the record; `Blob`
54/// references content held out-of-line in a content-addressed [`BlobStore`],
55/// so byte-identical bodies (e.g. code-graph slices shared across worktrees)
56/// are stored once. See ADR 0012.
57///
58/// [`BlobStore`]: crate::store::BlobStore
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub enum Body {
61    Inline(Vec<u8>),
62    /// Content stored out-of-line under `hash` in a [`BlobStore`]; `len` is the
63    /// referenced content's byte length. The record itself carries only the
64    /// reference — the bytes are fetched via `BlobStore::get_blob`.
65    ///
66    /// [`BlobStore`]: crate::store::BlobStore
67    Blob {
68        hash: ContentHash,
69        len: u64,
70    },
71}
72
73impl Body {
74    /// Build a blob body referencing `content` by its content hash. The content
75    /// itself is written separately via `BlobStore::put_blob`.
76    pub fn blob(content: &[u8]) -> Self {
77        Body::Blob {
78            hash: ContentHash::of(content),
79            len: content.len() as u64,
80        }
81    }
82
83    /// The bytes used for content hashing and merging. For a `Blob` these are
84    /// the reference's hash bytes, not the referenced content — identical
85    /// content yields an identical reference, so the record's revision is
86    /// stable under content-addressed dedup.
87    pub fn bytes(&self) -> &[u8] {
88        match self {
89            Body::Inline(b) => b,
90            Body::Blob { hash, .. } => hash.0.as_bytes(),
91        }
92    }
93}
94
95/// Provenance and labels for a record.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Meta {
98    pub author: Identity,
99    pub origin_system: String,
100    pub created: i64,
101    pub updated: i64,
102    pub labels: BTreeMap<String, String>,
103}
104
105/// The universal persisted unit.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Record {
108    pub key: RecordKey,
109    pub kind: RecordKind,
110    pub revision: Revision,
111    pub parent: Option<Revision>,
112    pub body: Body,
113    pub meta: Meta,
114    pub links: Vec<RecordKey>,
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn merge_class_is_assigned_per_kind() {
123        assert_eq!(RecordKind::Topic.merge_class(), MergeClass::AppendOnly);
124        assert_eq!(RecordKind::Session.merge_class(), MergeClass::AppendOnly);
125        assert_eq!(RecordKind::MemoryTier.merge_class(), MergeClass::Structured);
126        assert_eq!(RecordKind::Checkpoint.merge_class(), MergeClass::Opaque);
127        assert_eq!(RecordKind::Ticket.merge_class(), MergeClass::Structured);
128        assert_eq!(
129            RecordKind::TicketEvent.merge_class(),
130            MergeClass::AppendOnly
131        );
132        assert_eq!(RecordKind::GraphManifest.merge_class(), MergeClass::Derived);
133    }
134
135    #[test]
136    fn body_exposes_bytes() {
137        assert_eq!(Body::Inline(b"hi".to_vec()).bytes(), b"hi");
138    }
139
140    #[test]
141    fn blob_body_references_content_by_hash() {
142        let body = Body::blob(b"fn main() {}");
143        match &body {
144            Body::Blob { hash, len } => {
145                assert_eq!(*hash, crate::ContentHash::of(b"fn main() {}"));
146                assert_eq!(*len, 12);
147            }
148            _ => panic!("expected Body::Blob"),
149        }
150    }
151
152    #[test]
153    fn blob_body_bytes_are_stable_per_content() {
154        // Identical content -> identical body bytes -> identical revision (the
155        // record-level face of content-addressed dedup).
156        assert_eq!(Body::blob(b"same").bytes(), Body::blob(b"same").bytes());
157        assert_ne!(Body::blob(b"same").bytes(), Body::blob(b"diff").bytes());
158    }
159}