kcode-commit-session 0.1.1

Atomic, idempotent materialization of completed Kennedy sessions into Kweb
Documentation
use std::{collections::BTreeMap, sync::Arc};

use chrono::{TimeZone, Utc};
use kcode_commit_session::{CommitRequest, ErrorKind, PlannedNode, commit_session};
use kcode_kweb_db::{Config, KwebDb, NoopGossip, ObjectId, Owner, WriterId};
use rusqlite::{Connection, params};
use tempfile::TempDir;

struct Fixture {
    _root: TempDir,
    database: KwebDb,
    receipts: std::path::PathBuf,
}

impl Fixture {
    fn new() -> Self {
        let root = tempfile::tempdir().unwrap();
        let signing_key = [7; 32];
        let database = KwebDb::open(
            root.path().join("kweb"),
            Config {
                signing_key,
                writers_by_priority: vec![WriterId::from_signing_key(&signing_key)],
                gossip: Arc::new(NoopGossip),
            },
        )
        .unwrap();
        Self {
            receipts: root.path().join("identity.sqlite3"),
            database,
            _root: root,
        }
    }
}

fn node(owner: &str) -> PlannedNode {
    PlannedNode {
        short_name: "Node referring to pending:1".into(),
        short_description: "Object pending:1.".into(),
        long_description: "xpending:1 pending:1_suffix pending:1".into(),
        owner: owner.into(),
        fixed_connections: Vec::new(),
        recent_connections: Vec::new(),
        objects: vec!["pending:1".into()],
        attach_session_archive: true,
    }
}

fn request() -> CommitRequest {
    CommitRequest {
        idempotency_key: "session-1".into(),
        author: "Kennedy".into(),
        source_created_at: Utc.with_ymd_and_hms(2026, 7, 27, 1, 2, 3).unwrap(),
        archive: b"archive object pending:1".to_vec(),
        objects: BTreeMap::from([("pending:1".into(), b"encoded file".to_vec())]),
        creates: BTreeMap::from([
            ("pending:2".into(), node("pending:3")),
            ("pending:3".into(), node("pending:2")),
        ]),
        updates: BTreeMap::new(),
    }
}

#[test]
fn one_commit_resolves_objects_circular_nodes_and_archive_attachment() {
    let fixture = Fixture::new();
    let receipt = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();

    assert!(receipt.transaction_id.is_some());
    assert_eq!(
        fixture
            .database
            .get_object(receipt.object_ids["pending:1"])
            .unwrap(),
        b"encoded file"
    );
    assert_eq!(
        fixture
            .database
            .get_object(receipt.session_object_id)
            .unwrap(),
        format!("archive object {}", receipt.object_ids["pending:1"]).as_bytes()
    );

    let first = fixture
        .database
        .get_node(receipt.node_ids["pending:2"])
        .unwrap();
    let second = fixture
        .database
        .get_node(receipt.node_ids["pending:3"])
        .unwrap();
    assert_eq!(first.data.owner, Owner::Node(receipt.node_ids["pending:3"]));
    assert_eq!(
        second.data.owner,
        Owner::Node(receipt.node_ids["pending:2"])
    );
    assert!(first.data.objects.contains(&receipt.session_object_id));
    assert!(
        first
            .data
            .objects
            .contains(&receipt.object_ids["pending:1"])
    );
    assert!(
        first
            .data
            .short_name
            .contains(&receipt.object_ids["pending:1"].to_string())
    );
    assert!(first.data.long_description.contains("pending:1_suffix"));
}

#[test]
fn an_identical_replay_returns_the_same_receipt_and_changed_input_conflicts() {
    let fixture = Fixture::new();
    let first = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
    let replay = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
    assert_eq!(replay, first);

    let mut changed = request();
    changed.archive = b"different".to_vec();
    let error = commit_session(&fixture.database, &fixture.receipts, changed).unwrap_err();
    assert_eq!(error.kind(), ErrorKind::Conflict);
}

#[test]
fn a_prepared_receipt_recovers_after_the_kweb_transaction_became_visible() {
    let fixture = Fixture::new();
    let first = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
    let connection = Connection::open(&fixture.receipts).unwrap();
    connection
        .execute(
            "UPDATE kmap_session_commit_receipts
             SET result_json=NULL,committed_at=NULL
             WHERE session_id=?1",
            params!["session-1"],
        )
        .unwrap();
    drop(connection);

    let recovered = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
    assert_eq!(recovered.transaction_id, None);
    assert_eq!(recovered.session_object_id, first.session_object_id);
    assert_eq!(recovered.node_ids, first.node_ids);
    assert_eq!(recovered.object_ids, first.object_ids);
}

#[test]
fn legacy_kennedy_receipts_are_migrated_and_remain_replayable() {
    let fixture = Fixture::new();
    let session_object_id = ObjectId::from_bytes([128, 1, 2, 3, 4, 5]).unwrap();
    let legacy_receipt = serde_json::json!({
        "transactionId": null,
        "sessionObjectId": session_object_id.to_string(),
        "nodeIds": {},
        "objectIds": {},
    })
    .to_string();
    let connection = Connection::open(&fixture.receipts).unwrap();
    connection
        .execute_batch(
            "CREATE TABLE kmap_session_commit_receipts (
                 session_id TEXT PRIMARY KEY,
                 request_sha256 BLOB NOT NULL CHECK(length(request_sha256)=32),
                 result_json TEXT,
                 started_at TEXT NOT NULL,
                 committed_at TEXT,
                 CHECK((result_json IS NULL) = (committed_at IS NULL))
             );",
        )
        .unwrap();
    connection
        .execute(
            "INSERT INTO kmap_session_commit_receipts(
                 session_id,request_sha256,result_json,started_at,committed_at
             ) VALUES(?1,?2,?3,?4,?5)",
            params![
                "session-1",
                [9_u8; 32].as_slice(),
                legacy_receipt,
                "2026-07-27T01:02:03Z",
                "2026-07-27T01:02:04Z",
            ],
        )
        .unwrap();
    drop(connection);

    let replay = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
    assert_eq!(replay.session_object_id, session_object_id);
    let connection = Connection::open(&fixture.receipts).unwrap();
    assert_eq!(
        connection
            .query_row(
                "SELECT digest_version FROM kmap_session_commit_receipts
                 WHERE session_id='session-1'",
                [],
                |row| row.get::<_, i64>(0),
            )
            .unwrap(),
        1
    );
}

#[test]
fn unresolved_references_are_rejected_before_receipt_creation() {
    let fixture = Fixture::new();
    let mut input = request();
    input.creates.get_mut("pending:2").unwrap().owner = "pending:99".into();
    let error = commit_session(&fixture.database, &fixture.receipts, input).unwrap_err();
    assert_eq!(error.kind(), ErrorKind::InvalidInput);
    assert!(!fixture.receipts.exists());
}