Skip to main content

kcode_kweb_manager/
lib.rs

1//! Kennedy's typed application manager for one privately owned Kweb database.
2
3use std::{collections::BTreeMap, fmt, path::Path, str::FromStr, sync::Arc};
4
5use kcode_commit_session::{CommitReceipt, CommitRequest};
6pub use kcode_kmap_mutations::{
7    Operation as KmapOperation, Outcome as KmapOutcome, OwnerSelection as KmapOwnerSelection,
8};
9use kcode_kweb_db::{
10    Error as KwebError, KwebDb, Node, NodeHistory, NodeId, ObjectId, Owner, Provenance,
11    TransactionId,
12};
13use kcode_kweb_idempotency::{ExecuteError, ReceiptLane, VersionedDigest};
14use kcode_server_object_envelopes::{StoredProvenance, decode_provenance, encode_provenance};
15use sha2::{Digest, Sha256};
16
17const MAX_EMBEDDED_PROVENANCE_BYTES: usize = 1024 * 1024;
18
19/// Result returned by `kcode-kweb-manager`.
20pub type Result<T> = std::result::Result<T, Error>;
21
22/// Stable error category for application adapters.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24#[non_exhaustive]
25pub enum ErrorKind {
26    /// The caller supplied an invalid identifier or mutation.
27    InvalidInput,
28    /// A requested node, object, or provenance value does not exist.
29    NotFound,
30    /// Idempotency state or a concurrent database operation conflicts.
31    Conflict,
32    /// The global writer or its persistence is temporarily unavailable.
33    Unavailable,
34    /// Persistence is unavailable, corrupt, or otherwise failed internally.
35    Internal,
36}
37
38/// Error returned by a Kweb manager operation.
39#[derive(Debug)]
40pub struct Error {
41    kind: ErrorKind,
42    message: String,
43}
44
45impl Error {
46    /// Returns the stable category suitable for transport mapping.
47    pub fn kind(&self) -> ErrorKind {
48        self.kind
49    }
50
51    fn invalid(message: impl Into<String>) -> Self {
52        Self {
53            kind: ErrorKind::InvalidInput,
54            message: message.into(),
55        }
56    }
57
58    fn internal(error: impl fmt::Display) -> Self {
59        Self {
60            kind: ErrorKind::Internal,
61            message: error.to_string(),
62        }
63    }
64}
65
66impl fmt::Display for Error {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str(&self.message)
69    }
70}
71
72impl std::error::Error for Error {}
73
74impl From<KwebError> for Error {
75    fn from(error: KwebError) -> Self {
76        let kind = match error {
77            KwebError::InvalidInput(_) | KwebError::InvalidTransaction(_) => {
78                ErrorKind::InvalidInput
79            }
80            KwebError::NotFound(_) => ErrorKind::NotFound,
81            KwebError::Busy(_) => ErrorKind::Conflict,
82            KwebError::Io(_)
83            | KwebError::Corrupt(_)
84            | KwebError::InvalidConfig(_)
85            | KwebError::OfflineUpgradeRequired(_) => ErrorKind::Internal,
86        };
87        Self {
88            kind,
89            message: error.to_string(),
90        }
91    }
92}
93
94impl From<kcode_commit_session::Error> for Error {
95    fn from(error: kcode_commit_session::Error) -> Self {
96        let kind = match error.kind() {
97            kcode_commit_session::ErrorKind::InvalidInput => ErrorKind::InvalidInput,
98            kcode_commit_session::ErrorKind::NotFound => ErrorKind::NotFound,
99            kcode_commit_session::ErrorKind::Conflict => ErrorKind::Conflict,
100            _ => ErrorKind::Internal,
101        };
102        Self {
103            kind,
104            message: error.to_string(),
105        }
106    }
107}
108
109impl From<kcode_kmap_mutations::Error> for Error {
110    fn from(error: kcode_kmap_mutations::Error) -> Self {
111        let kind = match error.kind() {
112            kcode_kmap_mutations::ErrorKind::InvalidInput => ErrorKind::InvalidInput,
113            kcode_kmap_mutations::ErrorKind::NotFound => ErrorKind::NotFound,
114            kcode_kmap_mutations::ErrorKind::StaleRevision => ErrorKind::Conflict,
115            kcode_kmap_mutations::ErrorKind::Unavailable => ErrorKind::Unavailable,
116            kcode_kmap_mutations::ErrorKind::Internal => ErrorKind::Internal,
117            _ => ErrorKind::Internal,
118        };
119        Self {
120            kind,
121            message: error.to_string(),
122        }
123    }
124}
125
126/// One idempotent provenance-object creation.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct CreateProvenance {
129    /// Stable 16-byte request identity encoded as lowercase hexadecimal.
130    pub idempotency_id: String,
131    /// Application provenance envelope stored as the object payload.
132    pub value: StoredProvenance,
133    /// Kweb provenance describing the transaction that stores the envelope.
134    pub storage_provenance: Provenance,
135}
136
137/// Node fields editable by ordinary Kennedy Kweb mutations.
138///
139/// Object references are intentionally absent. New nodes start without
140/// objects, and updates preserve the current object list.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct NodeContents {
143    pub short_name: String,
144    pub short_description: String,
145    pub long_description: String,
146    pub owner: Owner,
147    pub fixed_connections: Vec<NodeId>,
148    pub recent_connections: Vec<NodeId>,
149}
150
151impl NodeContents {
152    fn into_data(self, objects: Vec<ObjectId>) -> kcode_kweb_db::NodeData {
153        kcode_kweb_db::NodeData {
154            short_name: self.short_name,
155            short_description: self.short_description,
156            long_description: self.long_description,
157            owner: self.owner,
158            fixed_connections: self.fixed_connections,
159            recent_connections: self.recent_connections,
160            objects,
161        }
162    }
163}
164
165/// Shared input for idempotent node creation and update.
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct NodeWrite {
168    /// Stable 16-byte request identity encoded as lowercase hexadecimal.
169    pub idempotency_id: String,
170    /// Stored application provenance used to describe the Kweb transaction.
171    pub provenance_id: ObjectId,
172    /// Author or model attribution recorded on the Kweb transaction.
173    pub author: String,
174    /// Complete ordinary node fields.
175    pub contents: NodeContents,
176}
177
178/// One idempotent immediate administrator Kmap mutation.
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct KmapWrite {
181    pub operation: KmapOperation,
182    pub expected_revisions: BTreeMap<NodeId, TransactionId>,
183    pub provenance: Provenance,
184}
185
186/// Cloneable sole owner of one live Kweb database handle.
187#[derive(Clone)]
188pub struct KwebManager {
189    database: Arc<KwebDb>,
190    receipts: ReceiptLane,
191}
192
193impl KwebManager {
194    /// Takes ownership of an open Kweb database and opens its receipt lane.
195    pub fn open(database: KwebDb, receipt_database: impl AsRef<Path>) -> Result<Self> {
196        let receipts = ReceiptLane::open(receipt_database).map_err(receipt_lane_error)?;
197        Ok(Self {
198            database: Arc::new(database),
199            receipts,
200        })
201    }
202
203    /// Loads one current node.
204    pub fn get_node(&self, id: NodeId) -> Result<Node> {
205        self.database.get_node(id).map_err(Error::from)
206    }
207
208    /// Loads one node's complete visible history.
209    pub fn get_node_history(&self, id: NodeId) -> Result<NodeHistory> {
210        self.database.get_node_history(id).map_err(Error::from)
211    }
212
213    /// Loads the exact opaque bytes of one Kweb object.
214    pub fn get_object(&self, id: ObjectId) -> Result<Vec<u8>> {
215        self.database.get_object(id).map_err(Error::from)
216    }
217
218    /// Loads exact bytes and provenance from the signed creating Kweb transaction.
219    pub fn get_object_with_provenance(&self, id: ObjectId) -> Result<(Vec<u8>, Provenance)> {
220        self.database
221            .get_object_with_provenance(id)
222            .map_err(Error::from)
223    }
224
225    /// Idempotently stores one application provenance envelope.
226    pub fn create_provenance(&self, request: CreateProvenance) -> Result<ObjectId> {
227        let encoded =
228            encode_provenance(&request.value).map_err(|error| Error::invalid(error.to_string()))?;
229        let legacy_digest = legacy_provenance_request_digest(&request);
230        let digest = provenance_request_digest(&encoded, &request.storage_provenance);
231        let storage_provenance = request.storage_provenance.clone();
232        let result = self.with_idempotency(
233            &request.idempotency_id,
234            "create_provenance",
235            VersionedDigest::v2(digest, legacy_digest),
236            |result_id| {
237                let id = ObjectId::from_str(result_id).map_err(|error| {
238                    Error::internal(format!("invalid stored provenance receipt: {error}"))
239                })?;
240                let (stored_bytes, creating_provenance) =
241                    self.database.get_object_with_provenance(id)?;
242                let stored_value = decode_provenance(&stored_bytes).map_err(|error| {
243                    Error::internal(format!("invalid stored provenance object {id}: {error}"))
244                })?;
245                Ok(stored_value == request.value
246                    && creating_provenance == request.storage_provenance)
247            },
248            || Ok(None),
249            || {
250                let mut transaction = self.database.start_transaction(storage_provenance)?;
251                let id = transaction.create_object(encoded)?;
252                transaction.finalize()?;
253                Ok(id.to_string())
254            },
255        )?;
256        let id = ObjectId::from_str(&result).map_err(|error| {
257            Error::internal(format!("invalid stored provenance receipt: {error}"))
258        })?;
259        Ok(id)
260    }
261
262    /// Idempotently creates a node without object attachments.
263    pub fn create_node(&self, request: NodeWrite) -> Result<Node> {
264        let digest = node_request_digest("create_node", None, &request);
265        let result =
266            self.with_idempotency(
267                &request.idempotency_id,
268                "create_node",
269                VersionedDigest::v1(digest),
270                |_| Ok(true),
271                || Ok(None),
272                || {
273                    let provenance = self.load_provenance(request.provenance_id)?;
274                    let mut transaction = self.database.start_transaction(
275                        transaction_provenance(&provenance, request.provenance_id, request.author),
276                    )?;
277                    let id = transaction.create_node(request.contents.into_data(Vec::new()))?;
278                    transaction.finalize()?;
279                    Ok(id.to_string())
280                },
281            )?;
282        let id = NodeId::from_str(&result)
283            .map_err(|error| Error::internal(format!("invalid stored node receipt: {error}")))?;
284        self.get_node(id)
285    }
286
287    /// Idempotently updates ordinary node fields while retaining its objects.
288    pub fn update_node(&self, id: NodeId, request: NodeWrite) -> Result<Node> {
289        let digest = node_request_digest("update_node", Some(id), &request);
290        self.with_idempotency(
291            &request.idempotency_id,
292            "update_node",
293            VersionedDigest::v1(digest),
294            |_| Ok(true),
295            || Ok(None),
296            || {
297                let provenance = self.load_provenance(request.provenance_id)?;
298                let objects = self.database.get_node(id)?.data.objects;
299                let mut transaction = self.database.start_transaction(transaction_provenance(
300                    &provenance,
301                    request.provenance_id,
302                    request.author,
303                ))?;
304                transaction.update_node(id, request.contents.into_data(objects))?;
305                transaction.finalize()?;
306                Ok(id.to_string())
307            },
308        )?;
309        self.get_node(id)
310    }
311
312    /// Idempotently applies one immediate administrator Kmap operation.
313    pub fn apply_kmap_operation(&self, request: KmapWrite) -> Result<KmapOutcome> {
314        let receipt_node = receipt_node(&request.operation)?;
315        if let Some(outcome) =
316            self.recover_kmap_operation(receipt_node, &request.operation, &request.provenance)?
317        {
318            return Ok(outcome);
319        }
320        kcode_kmap_mutations::apply(
321            &self.database,
322            kcode_kmap_mutations::Request {
323                operation: request.operation,
324                expected_revisions: request.expected_revisions,
325                provenance: request.provenance,
326            },
327        )
328        .map_err(Error::from)
329    }
330
331    /// Stores exact opaque object bytes in one Kweb transaction.
332    pub fn store_object(&self, provenance: Provenance, bytes: Vec<u8>) -> Result<ObjectId> {
333        let mut transaction = self.database.start_transaction(provenance)?;
334        let id = transaction.create_object(bytes)?;
335        transaction.finalize()?;
336        Ok(id)
337    }
338
339    /// Atomically commits one complete session through `kcode-commit-session`.
340    pub fn commit_session(&self, request: CommitRequest) -> Result<CommitReceipt> {
341        self.receipts
342            .with_exclusive(|path| {
343                kcode_commit_session::commit_session(&self.database, path, request)
344            })
345            .map_err(|error| map_execute_error(error, Error::from))
346    }
347
348    fn load_provenance(&self, id: ObjectId) -> Result<StoredProvenance> {
349        let bytes = self.database.get_object(id)?;
350        decode_provenance(&bytes).map_err(|error| {
351            Error::internal(format!("invalid stored provenance object {id}: {error}"))
352        })
353    }
354
355    fn recover_kmap_operation(
356        &self,
357        receipt_node: NodeId,
358        operation: &KmapOperation,
359        provenance: &Provenance,
360    ) -> Result<Option<KmapOutcome>> {
361        let history = match self.database.get_node_history(receipt_node) {
362            Ok(history) => history,
363            Err(KwebError::NotFound(_)) => return Ok(None),
364            Err(error) => return Err(error.into()),
365        };
366        let Some(entry) = history
367            .entries
368            .iter()
369            .find(|entry| entry.provenance == *provenance && (entry.created || entry.updated))
370        else {
371            return Ok(None);
372        };
373        let created_node_id = if matches!(operation, KmapOperation::CreateNode { .. }) {
374            entry
375                .data
376                .as_ref()
377                .and_then(|data| data.recent_connections.first())
378                .copied()
379        } else {
380            None
381        };
382        let mut affected_node_ids = mutated_nodes(operation);
383        if let Some(id) = created_node_id {
384            affected_node_ids.push(id);
385        }
386        Ok(Some(KmapOutcome {
387            transaction_id: entry.transaction_id,
388            created_node_id,
389            affected_node_ids,
390        }))
391    }
392
393    fn with_idempotency(
394        &self,
395        idempotency_id: &str,
396        operation: &'static str,
397        digest: VersionedDigest,
398        legacy_result_matches: impl FnOnce(&str) -> Result<bool>,
399        recover_unknown: impl FnOnce() -> Result<Option<String>>,
400        mutation: impl FnOnce() -> Result<String>,
401    ) -> Result<String> {
402        self.receipts
403            .execute(
404                idempotency_id,
405                operation,
406                digest,
407                legacy_result_matches,
408                recover_unknown,
409                mutation,
410            )
411            .map_err(|error| map_execute_error(error, |value| value))
412    }
413}
414
415fn receipt_lane_error(error: kcode_kweb_idempotency::Error) -> Error {
416    let kind = match error.kind() {
417        kcode_kweb_idempotency::ErrorKind::InvalidInput => ErrorKind::InvalidInput,
418        kcode_kweb_idempotency::ErrorKind::Conflict => ErrorKind::Conflict,
419        kcode_kweb_idempotency::ErrorKind::Internal => ErrorKind::Internal,
420        _ => ErrorKind::Internal,
421    };
422    Error {
423        kind,
424        message: error.to_string(),
425    }
426}
427
428fn map_execute_error<E>(error: ExecuteError<E>, operation: impl FnOnce(E) -> Error) -> Error {
429    match error {
430        ExecuteError::Lane(error) => receipt_lane_error(error),
431        ExecuteError::Operation(error) => operation(error),
432    }
433}
434
435fn receipt_node(operation: &KmapOperation) -> Result<NodeId> {
436    match operation {
437        KmapOperation::ConnectNodes(ids) => ids.first().copied(),
438        KmapOperation::ConsolidateFanout { parent, .. }
439        | KmapOperation::SetFixedConnection { parent, .. } => Some(*parent),
440        KmapOperation::CreateNode { parents, .. } => parents.first().copied(),
441        KmapOperation::UpdateNode { id, .. } => Some(*id),
442    }
443    .ok_or_else(|| Error::invalid("Kmap operation has no durable anchor node"))
444}
445
446fn mutated_nodes(operation: &KmapOperation) -> Vec<NodeId> {
447    let values = match operation {
448        KmapOperation::ConnectNodes(ids) => ids.clone(),
449        KmapOperation::ConsolidateFanout {
450            parent, aggregator, ..
451        } => {
452            vec![*parent, *aggregator]
453        }
454        KmapOperation::SetFixedConnection { parent, .. } => vec![*parent],
455        KmapOperation::CreateNode { parents, .. } => parents.clone(),
456        KmapOperation::UpdateNode { id, .. } => vec![*id],
457    };
458    values
459        .into_iter()
460        .collect::<std::collections::BTreeSet<_>>()
461        .into_iter()
462        .collect()
463}
464
465struct RequestDigest(Sha256);
466
467impl RequestDigest {
468    fn new(operation: &str) -> Self {
469        let mut hash = Sha256::new();
470        hash.update(b"kennedy kmap idempotency v1\0");
471        let mut value = Self(hash);
472        value.field(operation.as_bytes());
473        value
474    }
475
476    fn new_v2(operation: &str) -> Self {
477        let mut hash = Sha256::new();
478        hash.update(b"kennedy kweb manager idempotency v2\0");
479        let mut value = Self(hash);
480        value.field(operation.as_bytes());
481        value
482    }
483
484    fn field(&mut self, bytes: &[u8]) {
485        self.0.update((bytes.len() as u64).to_be_bytes());
486        self.0.update(bytes);
487    }
488
489    fn finish(self) -> [u8; 32] {
490        self.0.finalize().into()
491    }
492}
493
494fn provenance_request_digest(encoded: &[u8], storage: &Provenance) -> [u8; 32] {
495    let mut hash = RequestDigest::new_v2("create_provenance");
496    hash.field(encoded);
497    hash.field(storage.author.as_bytes());
498    hash.field(storage.source.as_bytes());
499    hash.field(&storage.source_created_at.timestamp().to_be_bytes());
500    hash.field(
501        &storage
502            .source_created_at
503            .timestamp_subsec_nanos()
504            .to_be_bytes(),
505    );
506    hash.field(storage.data.as_bytes());
507    hash.finish()
508}
509
510// Existing receipts used this incomplete digest. It remains only to replay an
511// exact legacy result after independently checking the stored Kweb object and
512// its signed creating provenance against the complete request.
513fn legacy_provenance_request_digest(request: &CreateProvenance) -> [u8; 32] {
514    let mut hash = RequestDigest::new("create_provenance");
515    hash.field(request.value.data.as_bytes());
516    hash.field(request.value.source.as_bytes());
517    hash.field(&request.value.source_created_at.timestamp().to_be_bytes());
518    hash.field(
519        &request
520            .value
521            .source_created_at
522            .timestamp_subsec_nanos()
523            .to_be_bytes(),
524    );
525    hash.field(b"");
526    hash.field(&(request.value.artifacts.len() as u64).to_be_bytes());
527    for artifact in &request.value.artifacts {
528        hash.field(artifact.original_filename.as_bytes());
529        hash.field(artifact.media_type.as_bytes());
530        hash.field(&artifact.sha256);
531    }
532    hash.field(request.storage_provenance.author.as_bytes());
533    hash.field(request.storage_provenance.source.as_bytes());
534    hash.field(
535        &request
536            .storage_provenance
537            .source_created_at
538            .timestamp()
539            .to_be_bytes(),
540    );
541    hash.field(
542        &request
543            .storage_provenance
544            .source_created_at
545            .timestamp_subsec_nanos()
546            .to_be_bytes(),
547    );
548    hash.field(request.storage_provenance.data.as_bytes());
549    hash.finish()
550}
551
552fn node_request_digest(operation: &str, id: Option<NodeId>, request: &NodeWrite) -> [u8; 32] {
553    let mut hash = RequestDigest::new(operation);
554    hash.field(&id.map(NodeId::to_bytes).unwrap_or([0; 6]));
555    hash.field(&request.provenance_id.to_bytes());
556    hash.field(request.author.as_bytes());
557    hash.field(request.contents.short_name.as_bytes());
558    hash.field(request.contents.short_description.as_bytes());
559    hash.field(request.contents.long_description.as_bytes());
560    match request.contents.owner {
561        Owner::Unowned => hash.field(&[0]),
562        Owner::SelfNode => hash.field(&[1]),
563        Owner::Node(owner) => {
564            hash.field(&[2]);
565            hash.field(&owner.to_bytes());
566        }
567    }
568    hash.field(&(request.contents.fixed_connections.len() as u64).to_be_bytes());
569    for connection in &request.contents.fixed_connections {
570        hash.field(&connection.to_bytes());
571    }
572    hash.field(&(request.contents.recent_connections.len() as u64).to_be_bytes());
573    for connection in &request.contents.recent_connections {
574        hash.field(&connection.to_bytes());
575    }
576    hash.finish()
577}
578
579fn transaction_provenance(
580    stored: &StoredProvenance,
581    object_id: ObjectId,
582    author: String,
583) -> Provenance {
584    let data = if stored.data.len() <= MAX_EMBEDDED_PROVENANCE_BYTES {
585        stored.data.clone()
586    } else {
587        format!("Kennedy provenance is stored in object {object_id}.")
588    };
589    Provenance {
590        author,
591        source: stored.source.clone(),
592        source_created_at: stored.source_created_at,
593        data,
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use std::{
600        collections::BTreeMap,
601        fs,
602        path::PathBuf,
603        sync::{
604            Arc,
605            atomic::{AtomicU64, Ordering},
606        },
607    };
608
609    use chrono::{Duration, TimeZone, Utc};
610    use kcode_commit_session::CommitRequest;
611    use kcode_kweb_db::{Config, NodeData, NoopGossip, WriterId};
612    use kcode_server_object_envelopes::{StoredArtifact, decode_provenance};
613    use rusqlite::{Connection, params};
614
615    use super::*;
616
617    static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(1);
618
619    struct TestDirectory(PathBuf);
620
621    impl TestDirectory {
622        fn new() -> Self {
623            let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
624            let path = std::env::temp_dir().join(format!(
625                "kcode-kweb-manager-test-{}-{sequence}",
626                std::process::id()
627            ));
628            fs::create_dir_all(&path).unwrap();
629            Self(path)
630        }
631
632        fn join(&self, value: &str) -> PathBuf {
633            self.0.join(value)
634        }
635    }
636
637    impl Drop for TestDirectory {
638        fn drop(&mut self) {
639            fs::remove_dir_all(&self.0).unwrap();
640        }
641    }
642
643    fn config() -> Config {
644        let signing_key = [7; 32];
645        Config {
646            signing_key,
647            writers_by_priority: vec![WriterId::from_signing_key(&signing_key)],
648            gossip: Arc::new(NoopGossip),
649        }
650    }
651
652    fn timestamp() -> chrono::DateTime<Utc> {
653        Utc.with_ymd_and_hms(2026, 7, 28, 12, 0, 0).unwrap()
654    }
655
656    fn transaction_provenance_for(label: &str) -> Provenance {
657        Provenance {
658            author: "test".into(),
659            source: label.into(),
660            source_created_at: timestamp(),
661            data: format!("{label} transaction"),
662        }
663    }
664
665    fn provenance_request(idempotency_id: &str, data: &str) -> CreateProvenance {
666        CreateProvenance {
667            idempotency_id: idempotency_id.into(),
668            value: StoredProvenance {
669                data: data.into(),
670                source: "test-source".into(),
671                source_created_at: timestamp(),
672                artifacts: Vec::new(),
673            },
674            storage_provenance: transaction_provenance_for("provenance-storage"),
675        }
676    }
677
678    fn artifact() -> StoredArtifact {
679        StoredArtifact {
680            object_id: ObjectId::from_bytes([0x80, 1, 2, 3, 4, 5]).unwrap(),
681            original_filename: "source.txt".into(),
682            media_type: "text/plain".into(),
683            role: "source".into(),
684            byte_length: 12,
685            sha256: [7; 32],
686        }
687    }
688
689    fn node_write(idempotency_id: &str, provenance_id: ObjectId, short_name: &str) -> NodeWrite {
690        NodeWrite {
691            idempotency_id: idempotency_id.into(),
692            provenance_id,
693            author: "test-model".into(),
694            contents: NodeContents {
695                short_name: short_name.into(),
696                short_description: "short".into(),
697                long_description: "long".into(),
698                owner: Owner::SelfNode,
699                fixed_connections: Vec::new(),
700                recent_connections: Vec::new(),
701            },
702        }
703    }
704
705    #[test]
706    fn provenance_and_node_mutations_are_idempotent_and_typed() {
707        let directory = TestDirectory::new();
708        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
709        let kmap = KwebManager::open(database, directory.join("application.sqlite3")).unwrap();
710
711        let create_request =
712            provenance_request("00000000000000000000000000000001", "source material");
713        let provenance_id = kmap.create_provenance(create_request.clone()).unwrap();
714        assert_eq!(
715            kmap.create_provenance(create_request).unwrap(),
716            provenance_id
717        );
718        let stored = decode_provenance(&kmap.get_object(provenance_id).unwrap()).unwrap();
719        assert_eq!(stored.data, "source material");
720
721        let conflict = kmap
722            .create_provenance(provenance_request(
723                "00000000000000000000000000000001",
724                "different material",
725            ))
726            .unwrap_err();
727        assert_eq!(conflict.kind(), ErrorKind::Conflict);
728
729        let write = node_write(
730            "00000000000000000000000000000002",
731            provenance_id,
732            "Created node",
733        );
734        let node = kmap.create_node(write.clone()).unwrap();
735        assert_eq!(kmap.create_node(write).unwrap(), node);
736        assert_eq!(kmap.get_node(node.id).unwrap(), node);
737        assert!(node.data.objects.is_empty());
738    }
739
740    #[test]
741    fn provenance_idempotency_includes_every_storage_provenance_field() {
742        let directory = TestDirectory::new();
743        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
744        let kmap = KwebManager::open(database, directory.join("application.sqlite3")).unwrap();
745
746        let request = provenance_request("00000000000000000000000000000005", "source material");
747        let provenance_id = kmap.create_provenance(request.clone()).unwrap();
748        assert_eq!(
749            kmap.create_provenance(request.clone()).unwrap(),
750            provenance_id
751        );
752
753        let mut changed_author = request.clone();
754        changed_author
755            .storage_provenance
756            .author
757            .push_str("-changed");
758        let mut changed_source = request.clone();
759        changed_source
760            .storage_provenance
761            .source
762            .push_str("-changed");
763        let mut changed_timestamp = request.clone();
764        changed_timestamp.storage_provenance.source_created_at += Duration::nanoseconds(1);
765        let mut changed_data = request.clone();
766        changed_data.storage_provenance.data.push_str("-changed");
767
768        for changed in [
769            changed_author,
770            changed_source,
771            changed_timestamp,
772            changed_data,
773        ] {
774            let conflict = kmap.create_provenance(changed).unwrap_err();
775            assert_eq!(conflict.kind(), ErrorKind::Conflict);
776        }
777
778        let (bytes, creating_provenance) = kmap.get_object_with_provenance(provenance_id).unwrap();
779        assert_eq!(decode_provenance(&bytes).unwrap(), request.value);
780        assert_eq!(creating_provenance, request.storage_provenance);
781    }
782
783    #[test]
784    fn provenance_idempotency_includes_every_artifact_field() {
785        let directory = TestDirectory::new();
786        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
787        let kmap = KwebManager::open(database, directory.join("application.sqlite3")).unwrap();
788        let mut request = provenance_request("00000000000000000000000000000006", "source material");
789        request.value.artifacts.push(artifact());
790        let provenance_id = kmap.create_provenance(request.clone()).unwrap();
791        assert_eq!(
792            kmap.create_provenance(request.clone()).unwrap(),
793            provenance_id
794        );
795
796        let mut changed_object = request.clone();
797        changed_object.value.artifacts[0].object_id =
798            ObjectId::from_bytes([0x80, 1, 2, 3, 4, 6]).unwrap();
799        let mut changed_filename = request.clone();
800        changed_filename.value.artifacts[0].original_filename = "other.txt".into();
801        let mut changed_media_type = request.clone();
802        changed_media_type.value.artifacts[0].media_type = "application/json".into();
803        let mut changed_role = request.clone();
804        changed_role.value.artifacts[0].role = "transcript".into();
805        let mut changed_size = request.clone();
806        changed_size.value.artifacts[0].byte_length += 1;
807        let mut changed_sha256 = request;
808        changed_sha256.value.artifacts[0].sha256[0] ^= 1;
809
810        for changed in [
811            changed_object,
812            changed_filename,
813            changed_media_type,
814            changed_role,
815            changed_size,
816            changed_sha256,
817        ] {
818            let conflict = kmap.create_provenance(changed).unwrap_err();
819            assert_eq!(conflict.kind(), ErrorKind::Conflict);
820        }
821    }
822
823    #[test]
824    fn legacy_provenance_receipts_replay_exactly_and_drop_duplicate_storage() {
825        let directory = TestDirectory::new();
826        let receipt_path = directory.join("application.sqlite3");
827        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
828        let mut request = provenance_request("00000000000000000000000000000007", "legacy source");
829        request.value.artifacts.push(artifact());
830        let encoded = encode_provenance(&request.value).unwrap();
831        let mut transaction = database
832            .start_transaction(request.storage_provenance.clone())
833            .unwrap();
834        let object_id = transaction.create_object(encoded).unwrap();
835        transaction.finalize().unwrap();
836
837        let receipts = Connection::open(&receipt_path).unwrap();
838        receipts
839            .execute_batch(
840                "CREATE TABLE kmap_idempotency_receipts (
841                     idempotency_id TEXT PRIMARY KEY CHECK(length(idempotency_id)=32),
842                     operation TEXT NOT NULL,
843                     request_sha256 BLOB NOT NULL CHECK(length(request_sha256)=32),
844                     result_id TEXT CHECK(result_id IS NULL OR length(result_id)=8),
845                     started_at TEXT NOT NULL,
846                     committed_at TEXT,
847                     CHECK((result_id IS NULL) = (committed_at IS NULL))
848                 );
849                 CREATE TABLE kmap_object_provenance(object_id TEXT PRIMARY KEY);",
850            )
851            .unwrap();
852        receipts
853            .execute(
854                "INSERT INTO kmap_idempotency_receipts(
855                     idempotency_id,operation,request_sha256,result_id,started_at,committed_at
856                 ) VALUES(?1,'create_provenance',?2,?3,?4,?4)",
857                params![
858                    &request.idempotency_id,
859                    legacy_provenance_request_digest(&request).as_slice(),
860                    object_id.to_string(),
861                    Utc::now().to_rfc3339(),
862                ],
863            )
864            .unwrap();
865        drop(receipts);
866
867        let kmap = KwebManager::open(database, &receipt_path).unwrap();
868        assert_eq!(kmap.create_provenance(request.clone()).unwrap(), object_id);
869        let mut changed_role = request;
870        changed_role.value.artifacts[0].role = "different".into();
871        let conflict = kmap.create_provenance(changed_role).unwrap_err();
872        assert_eq!(conflict.kind(), ErrorKind::Conflict);
873
874        let receipts = Connection::open(receipt_path).unwrap();
875        let duplicate_table_count = receipts
876            .query_row(
877                "SELECT COUNT(*) FROM sqlite_master
878                 WHERE type='table' AND name='kmap_object_provenance'",
879                [],
880                |row| row.get::<_, i64>(0),
881            )
882            .unwrap();
883        assert_eq!(duplicate_table_count, 0);
884        let digest_version = receipts
885            .query_row(
886                "SELECT digest_version FROM kmap_idempotency_receipts
887                 WHERE idempotency_id=?1",
888                [&"00000000000000000000000000000007"],
889                |row| row.get::<_, i64>(0),
890            )
891            .unwrap();
892        assert_eq!(digest_version, 1);
893    }
894
895    #[test]
896    fn ordinary_updates_preserve_existing_object_attachments() {
897        let directory = TestDirectory::new();
898        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
899        let mut transaction = database
900            .start_transaction(transaction_provenance_for("seed"))
901            .unwrap();
902        let object_id = transaction.create_object(b"attachment".to_vec()).unwrap();
903        let node_id = transaction
904            .create_node(NodeData {
905                short_name: "Before".into(),
906                short_description: String::new(),
907                long_description: String::new(),
908                owner: Owner::SelfNode,
909                fixed_connections: Vec::new(),
910                recent_connections: Vec::new(),
911                objects: vec![object_id],
912            })
913            .unwrap();
914        transaction.finalize().unwrap();
915
916        let kmap = KwebManager::open(database, directory.join("application.sqlite3")).unwrap();
917        let (stored_bytes, stored_provenance) = kmap.get_object_with_provenance(object_id).unwrap();
918        assert_eq!(stored_bytes, b"attachment");
919        assert_eq!(stored_provenance, transaction_provenance_for("seed"));
920
921        let provenance_id = kmap
922            .create_provenance(provenance_request(
923                "00000000000000000000000000000003",
924                "update source",
925            ))
926            .unwrap();
927        let updated = kmap
928            .update_node(
929                node_id,
930                node_write("00000000000000000000000000000004", provenance_id, "After"),
931            )
932            .unwrap();
933        assert_eq!(updated.data.short_name, "After");
934        assert_eq!(updated.data.objects, vec![object_id]);
935    }
936
937    #[test]
938    fn object_storage_and_session_commits_share_the_owned_database() {
939        let directory = TestDirectory::new();
940        let database = KwebDb::open(directory.join("kweb"), config()).unwrap();
941        let kmap = KwebManager::open(database, directory.join("application.sqlite3")).unwrap();
942
943        let opaque_provenance = transaction_provenance_for("opaque-object");
944        let object_id = kmap
945            .store_object(opaque_provenance.clone(), b"opaque bytes".to_vec())
946            .unwrap();
947        assert_eq!(kmap.get_object(object_id).unwrap(), b"opaque bytes");
948        let (opaque_bytes, retained_provenance) =
949            kmap.get_object_with_provenance(object_id).unwrap();
950        assert_eq!(opaque_bytes, b"opaque bytes");
951        assert_eq!(retained_provenance, opaque_provenance);
952
953        let request = CommitRequest {
954            idempotency_key: "session-test".into(),
955            author: "test-model".into(),
956            source_created_at: timestamp(),
957            archive: b"{\"events\":[]}".to_vec(),
958            objects: BTreeMap::new(),
959            creates: BTreeMap::new(),
960            updates: BTreeMap::new(),
961        };
962        let first = kmap.commit_session(request.clone()).unwrap();
963        assert_eq!(
964            kmap.get_object(first.session_object_id).unwrap(),
965            b"{\"events\":[]}"
966        );
967        let (archive, provenance) = kmap
968            .get_object_with_provenance(first.session_object_id)
969            .unwrap();
970        assert_eq!(archive, b"{\"events\":[]}");
971        assert_eq!(provenance.author, "test-model");
972        assert_eq!(provenance.source, "kennedy-session");
973        assert_eq!(kmap.commit_session(request).unwrap(), first);
974    }
975}