Skip to main content

kcode_kweb_db/
model.rs

1use crate::{NodeId, ObjectId, TransactionId, WriterId};
2use chrono::{DateTime, Utc};
3use std::{fmt, sync::Arc};
4
5pub const MAX_OBJECT_BYTES: u64 = 32 * 1024 * 1024 * 1024;
6pub const MAX_TRANSACTION_OBJECT_BYTES: u64 = MAX_OBJECT_BYTES;
7pub(crate) const MAX_STRING_BYTES: usize = 1024 * 1024;
8pub(crate) const MAX_TRANSACTION_BYTES: usize = 64 * 1024 * 1024;
9
10pub type Result<T> = std::result::Result<T, Error>;
11
12#[derive(Debug)]
13pub enum Error {
14    Io(std::io::Error),
15    Busy(String),
16    Corrupt(String),
17    InvalidConfig(String),
18    InvalidInput(String),
19    InvalidTransaction(String),
20    NotFound(String),
21    OfflineUpgradeRequired(String),
22}
23
24impl Error {
25    pub(crate) fn corrupt(message: impl Into<String>) -> Self {
26        Self::Corrupt(message.into())
27    }
28
29    pub(crate) fn invalid_config(message: impl Into<String>) -> Self {
30        Self::InvalidConfig(message.into())
31    }
32
33    pub(crate) fn invalid_input(message: impl Into<String>) -> Self {
34        Self::InvalidInput(message.into())
35    }
36
37    pub(crate) fn invalid_transaction(message: impl Into<String>) -> Self {
38        Self::InvalidTransaction(message.into())
39    }
40
41    pub(crate) fn not_found(message: impl Into<String>) -> Self {
42        Self::NotFound(message.into())
43    }
44
45    pub(crate) fn offline_upgrade(message: impl Into<String>) -> Self {
46        Self::OfflineUpgradeRequired(message.into())
47    }
48}
49
50impl fmt::Display for Error {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Io(error) => write!(formatter, "I/O error: {error}"),
54            Self::Busy(message) => write!(formatter, "database is busy: {message}"),
55            Self::Corrupt(message) => write!(formatter, "database corruption: {message}"),
56            Self::InvalidConfig(message) => write!(formatter, "invalid configuration: {message}"),
57            Self::InvalidInput(message) => write!(formatter, "invalid input: {message}"),
58            Self::InvalidTransaction(message) => {
59                write!(formatter, "invalid transaction: {message}")
60            }
61            Self::NotFound(message) => write!(formatter, "not found: {message}"),
62            Self::OfflineUpgradeRequired(message) => {
63                write!(formatter, "offline upgrade required: {message}")
64            }
65        }
66    }
67}
68
69impl std::error::Error for Error {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        match self {
72            Self::Io(error) => Some(error),
73            _ => None,
74        }
75    }
76}
77
78impl From<std::io::Error> for Error {
79    fn from(error: std::io::Error) -> Self {
80        Self::Io(error)
81    }
82}
83
84pub trait Gossip: Send + Sync + 'static {
85    fn announce(&self, package: TransactionPackage) -> bool;
86}
87
88pub trait TransactionSource: Send + Sync {
89    fn request_transactions(&self, transactions: Vec<TransactionId>);
90}
91
92#[derive(Clone, Copy, Debug, Default)]
93pub struct NoopGossip;
94
95impl Gossip for NoopGossip {
96    fn announce(&self, _package: TransactionPackage) -> bool {
97        true
98    }
99}
100
101#[derive(Clone)]
102pub struct Config {
103    pub signing_key: [u8; 32],
104    pub writers_by_priority: Vec<WriterId>,
105    pub gossip: Arc<dyn Gossip>,
106}
107
108impl fmt::Debug for Config {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter
111            .debug_struct("Config")
112            .field("signing_key", &"[redacted]")
113            .field("writers_by_priority", &self.writers_by_priority)
114            .field("gossip", &"dyn Gossip")
115            .finish()
116    }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct Provenance {
121    pub author: String,
122    pub source: String,
123    pub source_created_at: DateTime<Utc>,
124    pub data: String,
125}
126
127impl Provenance {
128    pub(crate) fn validate(&self) -> Result<()> {
129        if self.author.trim().is_empty() || self.author.len() > MAX_STRING_BYTES {
130            return Err(Error::invalid_input(
131                "provenance author is empty or too large",
132            ));
133        }
134        if self.source.trim().is_empty() || self.source.len() > MAX_STRING_BYTES {
135            return Err(Error::invalid_input(
136                "provenance source is empty or too large",
137            ));
138        }
139        if self.data.len() > MAX_STRING_BYTES {
140            return Err(Error::invalid_input("provenance data exceeds 1 MiB"));
141        }
142        Ok(())
143    }
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub enum Owner {
148    Unowned,
149    SelfNode,
150    Node(NodeId),
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct NodeData {
155    pub short_name: String,
156    pub short_description: String,
157    pub long_description: String,
158    pub owner: Owner,
159    pub fixed_connections: Vec<NodeId>,
160    pub recent_connections: Vec<NodeId>,
161    pub objects: Vec<ObjectId>,
162}
163
164impl NodeData {
165    pub(crate) fn validate(&self) -> Result<()> {
166        let characters = self.short_name.chars().count();
167        if !(4..=50).contains(&characters) {
168            return Err(Error::invalid_input(
169                "short_name must contain between 4 and 50 characters",
170            ));
171        }
172        if self.short_description.chars().count() > 200 {
173            return Err(Error::invalid_input(
174                "short_description exceeds 200 characters",
175            ));
176        }
177        if self.short_name.len() > MAX_STRING_BYTES
178            || self.short_description.len() > MAX_STRING_BYTES
179            || self.long_description.len() > MAX_STRING_BYTES
180        {
181            return Err(Error::invalid_input("node text exceeds 1 MiB"));
182        }
183        ensure_unique(&self.fixed_connections, "fixed_connections")?;
184        ensure_unique(&self.recent_connections, "recent_connections")?;
185        ensure_unique(&self.objects, "objects")?;
186        if self.fixed_connections.iter().any(|id| !id.valid_domain())
187            || self.recent_connections.iter().any(|id| !id.valid_domain())
188            || self.objects.iter().any(|id| !id.valid_domain())
189            || matches!(self.owner, Owner::Node(id) if !id.valid_domain())
190        {
191            return Err(Error::invalid_input(
192                "node contains an identifier in the wrong type domain",
193            ));
194        }
195        Ok(())
196    }
197}
198
199fn ensure_unique<T: Copy + Ord>(values: &[T], name: &str) -> Result<()> {
200    let mut ordered = values.to_vec();
201    ordered.sort_unstable();
202    if ordered.windows(2).any(|pair| pair[0] == pair[1]) {
203        return Err(Error::invalid_input(format!(
204            "{name} must contain unique IDs"
205        )));
206    }
207    Ok(())
208}
209
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct Node {
212    pub id: NodeId,
213    pub data: NodeData,
214    pub last_author: String,
215    pub committed_at: DateTime<Utc>,
216}
217
218#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
219pub struct MergePair {
220    pub first: TransactionId,
221    pub second: TransactionId,
222}
223
224impl MergePair {
225    pub fn new(first: TransactionId, second: TransactionId) -> Result<Self> {
226        if first == second {
227            return Err(Error::invalid_input(
228                "a merge pair must name two different transactions",
229            ));
230        }
231        let (first, second) = if first < second {
232            (first, second)
233        } else {
234            (second, first)
235        };
236        Ok(Self { first, second })
237    }
238}
239
240#[derive(Clone, Debug, Eq, PartialEq)]
241pub struct HistoryEntry {
242    pub transaction_id: TransactionId,
243    pub writer: WriterId,
244    pub committed_at: DateTime<Utc>,
245    pub provenance: Provenance,
246    pub active: bool,
247    pub created: bool,
248    pub updated: bool,
249    pub data: Option<NodeData>,
250    pub merge_pairs: Vec<MergePair>,
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct NodeHistory {
255    pub node_id: NodeId,
256    pub frontier: Vec<TransactionId>,
257    pub visible: Option<TransactionId>,
258    pub entries: Vec<HistoryEntry>,
259}
260
261#[derive(Eq, PartialEq)]
262pub struct ObjectPayload {
263    pub id: ObjectId,
264    pub bytes: Vec<u8>,
265}
266
267impl fmt::Debug for ObjectPayload {
268    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269        formatter
270            .debug_struct("ObjectPayload")
271            .field("id", &self.id)
272            .field("length", &self.bytes.len())
273            .finish()
274    }
275}
276
277#[derive(Eq, PartialEq)]
278pub struct TransactionPackage {
279    pub transaction: Vec<u8>,
280    pub objects: Vec<ObjectPayload>,
281}
282
283impl fmt::Debug for TransactionPackage {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        formatter
286            .debug_struct("TransactionPackage")
287            .field("transaction_bytes", &self.transaction.len())
288            .field("objects", &self.objects)
289            .finish()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn long_description_has_no_database_word_limit() {
299        let data = NodeData {
300            short_name: "Long description node".into(),
301            short_description: "Database validation must not enforce Chatend policy.".into(),
302            long_description: std::iter::repeat_n("word", 1_001)
303                .collect::<Vec<_>>()
304                .join(" "),
305            owner: Owner::SelfNode,
306            fixed_connections: Vec::new(),
307            recent_connections: Vec::new(),
308            objects: Vec::new(),
309        };
310
311        assert!(data.long_description.split_whitespace().count() > 1_000);
312        data.validate().unwrap();
313    }
314}