Skip to main content

kmp_adapter_embedded/adapter/
node_card.rs

1use kmp_domain::{
2    AuthorNodeCard, NodeCard, NodeCardEvent, NodeCardStore, NodeCardWriteFuture, PortError,
3    node_card_policy,
4};
5
6use super::engine::{Key, ReadTx, Table, WriteTx};
7use super::serdes::{AggregateRecord, CardRecord, NodeRecord, decode, encode};
8use super::store::EmbeddedKernelStore;
9
10/// Cards in requested order, preserving duplicates and missing slots.
11///
12/// Takes the caller's transaction, so a trace presents cards from the same
13/// snapshot it read descriptors and adjacency from. A card read against a
14/// later snapshot could describe a body this response never showed.
15pub(super) fn read_batch(
16    tx: &dyn ReadTx,
17    node_ids: &[String],
18    language: &str,
19) -> Result<Vec<Option<NodeCard>>, PortError> {
20    node_ids
21        .iter()
22        .map(|id| {
23            tx.get(Table::Cards, Key::Str2(id, language))?
24                .map(|raw| decode::<CardRecord>("node card", &raw).map(Into::into))
25                .transpose()
26        })
27        .collect()
28}
29
30fn stored_card(
31    tx: &dyn WriteTx,
32    node_id: &str,
33    language: &str,
34) -> Result<Option<NodeCard>, PortError> {
35    tx.get(Table::Cards, Key::Str2(node_id, language))?
36        .map(|raw| decode::<CardRecord>("node card", &raw).map(Into::into))
37        .transpose()
38}
39
40impl NodeCardStore for EmbeddedKernelStore {
41    fn author_node_card(&self, command: AuthorNodeCard) -> NodeCardWriteFuture<'_> {
42        Box::pin(async move {
43            self.run(move |store| {
44                // One write transaction for the whole decision. The node, its
45                // body descriptor and the stored card are read here, inside
46                // the lock the write already holds, so nothing can move
47                // between the check and the insert — which is the only thing
48                // that makes the declared source version mean anything.
49                let mut tx = store.begin_write()?;
50                let node = tx
51                    .get(Table::Nodes, Key::Str(&command.node_id))?
52                    .map(|raw| decode::<NodeRecord>("card node", &raw)?.into_projection())
53                    .transpose()?;
54                // The header, never the record. A reader already paid to read
55                // the body it is writing about; the kernel checking that write
56                // must not pay for it a second time.
57                let descriptor =
58                    super::node_body_descriptor::read_one(tx.as_ref(), &command.node_id)?;
59                let existing = stored_card(tx.as_ref(), &command.node_id, &command.language)?;
60
61                let admitted = match node_card_policy::admit(
62                    &command,
63                    node.as_ref(),
64                    descriptor.as_ref(),
65                    existing.as_ref(),
66                    None,
67                ) {
68                    Ok(card) => card,
69                    // Dropping the transaction discards it: a refused card
70                    // leaves the store exactly as it found it.
71                    Err(rejection) => return Ok(Err(rejection)),
72                };
73
74                append_event(tx.as_mut(), &command.about, &admitted, false)?;
75                project(tx.as_mut(), &admitted)?;
76                tx.commit()?;
77                Ok(Ok(admitted))
78            })
79            .await
80        })
81    }
82}
83
84#[cfg(test)]
85#[path = "node_card_tests.rs"]
86mod tests;
87
88/// Both live writes and replay use exactly the same card projection.
89pub(super) fn project(tx: &mut dyn WriteTx, card: &NodeCard) -> Result<(), PortError> {
90    if let Some(previous) = stored_card(tx, &card.node_id, &card.language)?
91        && previous != *card
92        && previous.card_revision.checked_add(1) != Some(card.card_revision)
93    {
94        return Err(PortError::InvalidState(
95            "non-contiguous card projection".into(),
96        ));
97    }
98    let bytes = encode("node card", &CardRecord::from(card.clone()))?;
99    let revision = version_key(card)?;
100    let key = Key::Str3(&card.node_id, &card.language, &revision);
101    if let Some(previous) = tx.get(Table::CardVersions, key)?
102        && previous != bytes
103    {
104        return Err(PortError::InvalidState(
105            "immutable card revision differs".into(),
106        ));
107    }
108    tx.insert(Table::CardVersions, key, &bytes)?;
109    tx.insert(
110        Table::Cards,
111        Key::Str2(&card.node_id, &card.language),
112        &bytes,
113    )
114}
115
116pub(super) fn append_event(
117    tx: &mut dyn WriteTx,
118    about: &str,
119    card: &NodeCard,
120    baseline: bool,
121) -> Result<(), PortError> {
122    let event = NodeCardEvent::record(about, card, baseline, std::time::SystemTime::now())?;
123    NodeCardEvent::card(&event)?;
124    let key = super::store::aggregate_key(about, NodeCardEvent::ROLE);
125    let revision = tx
126        .get(Table::Aggregates, Key::Str(&key))?
127        .map(|raw| decode::<AggregateRecord>("card stream head", &raw).map(|head| head.revision))
128        .transpose()?
129        .unwrap_or(0);
130    super::context_events::append_in_transaction(tx, event, revision)?;
131    Ok(())
132}
133
134/// A historical read selects the latest authored revision available at the cut.
135/// If none existed yet, keep the current metadata so presentation reports
136/// `after_cut` without disclosing its prose.
137pub(super) fn read_batch_at(
138    tx: &dyn ReadTx,
139    ids: &[String],
140    language: &str,
141    cut: Option<i128>,
142) -> Result<Vec<Option<NodeCard>>, PortError> {
143    let Some(cut) = cut else {
144        return read_batch(tx, ids, language);
145    };
146    let bound = history_key(cut, u64::MAX);
147    ids.iter()
148        .map(|id| {
149            let selected = tx
150                .last_str3_before(Table::CardVersions, id, language, &bound)?
151                .map(|raw| decode::<CardRecord>("card revision", &raw).map(Into::into))
152                .transpose()?;
153            match selected {
154                Some(card) => Ok(Some(card)),
155                None => tx
156                    .get(Table::Cards, Key::Str2(id, language))?
157                    .map(|raw| decode::<CardRecord>("node card", &raw).map(Into::into))
158                    .transpose(),
159            }
160        })
161        .collect()
162}
163
164/// Flipping the sign bit gives fixed-width lexical ordering over signed
165/// nanoseconds. The revision breaks ties when two authors share an instant.
166fn history_key(nanos: i128, revision: u64) -> String {
167    let ordered = (nanos as u128) ^ (1u128 << 127);
168    format!("{ordered:039}:{revision:020}")
169}
170
171pub(super) fn version_key(card: &NodeCard) -> Result<String, PortError> {
172    let nanos = kmp_domain::temporal_instant_nanos(&card.authored_at)
173        .ok_or_else(|| PortError::InvalidState("invalid card authorship instant".into()))?;
174    Ok(history_key(nanos, card.card_revision))
175}