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