Skip to main content

khive_runtime/
note_create.rs

1//! Shared note creation with explicit key-publication order.
2
3use khive_storage::types::{Edge, LinkId, SqlValue};
4use khive_storage::{EdgeRelation, SqlStatement};
5use serde_json::{json, Value};
6use uuid::Uuid;
7
8use crate::atomic_message::{
9    prepare_atomic_notes, AtomicNoteOptions, AtomicNoteSpec, PreparedAtomicNotes,
10};
11use crate::atomic_plan::{AffectedRowGuard, PlanStatement};
12use crate::atomic_runner::AtomicOpPlan;
13use crate::{KhiveRuntime, RuntimeError, RuntimeResult};
14
15/// The effective tags for note creation, retaining their source so writers can
16/// preserve the original properties when no top-level override is needed.
17pub enum EffectiveCreateTags<'a> {
18    TopLevel(&'a [String]),
19    Properties(Option<&'a Value>),
20}
21
22impl EffectiveCreateTags<'_> {
23    /// Project only the selected tags for a kind-specific validator. Property
24    /// values remain untyped here; each note kind owns their validation.
25    pub fn to_value(&self) -> Value {
26        match self {
27            Self::TopLevel(tags) => json!(tags),
28            Self::Properties(tags) => tags.cloned().unwrap_or(Value::Null),
29        }
30    }
31}
32
33/// Resolve create-tag precedence once for writers and kind hooks.
34///
35/// Nonempty top-level tags override `properties.tags`; absent, null (already
36/// deserialized as `None`), or empty top-level tags preserve that property.
37/// This differs from update semantics, where an empty tag array clears tags.
38pub fn effective_create_tags<'a>(
39    tags: Option<&'a [String]>,
40    properties: Option<&'a Value>,
41) -> EffectiveCreateTags<'a> {
42    match tags {
43        Some(tags) if !tags.is_empty() => EffectiveCreateTags::TopLevel(tags),
44        _ => EffectiveCreateTags::Properties(properties.and_then(|value| value.get("tags"))),
45    }
46}
47
48pub(crate) enum KeyPublication {
49    AtInsert,
50    AfterDependents,
51}
52
53pub(crate) const KEY_CLAIM: &str = "memory-key-claim";
54
55pub(crate) async fn prepare_note_create(
56    runtime: &KhiveRuntime,
57    spec: AtomicNoteSpec<'_>,
58    mut options: AtomicNoteOptions<'_>,
59    annotates: &[Uuid],
60    publication: KeyPublication,
61) -> RuntimeResult<(PreparedAtomicNotes, Vec<Uuid>)> {
62    for target in annotates {
63        if !runtime.substrate_exists_by_id(spec.token, *target).await? {
64            return Err(RuntimeError::NotFound(format!(
65                "create_note annotates target {target} not found"
66            )));
67        }
68    }
69    let claim_last = match publication {
70        KeyPublication::AtInsert => None,
71        KeyPublication::AfterDependents => Some(
72            options
73                .key
74                .take()
75                .ok_or_else(|| {
76                    RuntimeError::InvalidInput("deferred key publication requires a key".into())
77                })?
78                .to_owned(),
79        ),
80    };
81    let mut prepared = prepare_atomic_notes(runtime, vec![spec], options).await?;
82    let note = &prepared.notes[0];
83    crate::secret_gate::reject_reserved_secret_gate_property(note.properties.as_ref())?;
84    let AtomicOpPlan::AddNote(plan) = &mut prepared.plans[0] else {
85        return Err(RuntimeError::Internal(
86            "expected prepared note creation".into(),
87        ));
88    };
89    if claim_last.is_some() {
90        plan.statements[0].statement =
91            khive_db::stores::note::note_insert_if_absent_statement(note);
92    }
93    let mut annotation_ids = Vec::with_capacity(annotates.len());
94    for target in annotates {
95        let now = chrono::Utc::now();
96        let id = Uuid::new_v4();
97        let edge = Edge {
98            id: LinkId::from(id),
99            namespace: note.namespace.clone(),
100            source_id: note.id,
101            target_id: *target,
102            relation: EdgeRelation::Annotates,
103            weight: 1.0,
104            created_at: now,
105            updated_at: now,
106            deleted_at: None,
107            metadata: None,
108            target_backend: None,
109        };
110        plan.statements.push(PlanStatement {
111            statement: khive_db::stores::graph::edge_insert_only_guarded_by_endpoints_statement(
112                &edge,
113            ),
114            guard: Some(AffectedRowGuard::exactly(1)),
115        });
116        annotation_ids.push(id);
117    }
118    if let Some(key) = claim_last {
119        plan.statements.push(PlanStatement {
120            statement: SqlStatement {
121                sql: "UPDATE OR IGNORE notes SET key = ?1 WHERE id = ?2 AND namespace = ?3 \
122                      AND kind = ?4 AND key IS NULL AND deleted_at IS NULL"
123                    .into(),
124                params: vec![
125                    SqlValue::Text(key),
126                    SqlValue::Text(note.id.to_string()),
127                    SqlValue::Text(note.namespace.clone()),
128                    SqlValue::Text(note.kind.clone()),
129                ],
130                label: Some(KEY_CLAIM.into()),
131            },
132            guard: Some(AffectedRowGuard::exactly(1)),
133        });
134    }
135    Ok((prepared, annotation_ids))
136}