1use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use uuid::Uuid;
15
16use khive_storage::error::StorageError;
17#[cfg(test)]
18use khive_storage::error::WriterTaskRequestState;
19use khive_storage::event::{
20 Event, EventAppendDisposition, EventFilter, EventGroupBy, EventObservation,
21 IdempotentEventBatchResult, ObservationRole, ReferentKind,
22};
23use khive_storage::types::{BatchWriteSummary, Page, PageRequest, SqlStatement, SqlValue};
24use khive_storage::EventStore;
25use khive_storage::SqlWriter;
26use khive_storage::StorageCapability;
27use khive_types::{EventKind, EventOutcome, SubstrateKind};
28
29use crate::pool::ConnectionPool;
30use crate::writer_task::WriterTaskHandle;
31
32#[path = "event_cursor.rs"]
33mod cursor;
34
35fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
36 StorageError::driver(StorageCapability::Events, op, e)
37}
38
39#[cfg(test)]
41fn mark_unknown_append_usage(error: &StorageError) {
42 khive_storage::usage::account_event_write(Err(error));
43}
44
45pub struct SqlEventStore {
47 pool: Arc<ConnectionPool>,
48 is_file_backed: bool,
49 namespace: String,
50 writer_task: Option<WriterTaskHandle>,
51 #[cfg(test)]
52 lose_next_fallback_reply: std::sync::atomic::AtomicBool,
53}
54
55impl SqlEventStore {
56 pub fn new_scoped(
58 pool: Arc<ConnectionPool>,
59 is_file_backed: bool,
60 namespace: impl Into<String>,
61 ) -> Self {
62 let writer_task = pool.writer_task_handle().ok().flatten();
68 Self {
69 pool,
70 is_file_backed,
71 namespace: namespace.into(),
72 writer_task,
73 #[cfg(test)]
74 lose_next_fallback_reply: std::sync::atomic::AtomicBool::new(false),
75 }
76 }
77
78 #[cfg(test)]
79 fn after_fallback_write_for_test<T>(
80 &self,
81 result: Result<T, StorageError>,
82 ) -> Result<T, StorageError> {
83 if result.is_ok()
85 && self
86 .lose_next_fallback_reply
87 .swap(false, std::sync::atomic::Ordering::SeqCst)
88 {
89 return Err(StorageError::writer_task_terminated(
90 WriterTaskRequestState::SideEffectsUnknown,
91 ));
92 }
93 result
94 }
95
96 fn current_writer_task(
97 &self,
98 operation: &'static str,
99 ) -> Result<Option<WriterTaskHandle>, StorageError> {
100 self.pool
101 .writer_task_for_write(self.writer_task.as_ref(), operation)
102 }
103
104 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
117 where
118 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
119 R: Send + 'static,
120 {
121 if let Some(writer_task) = self.current_writer_task(op)? {
122 return writer_task
123 .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
124 .await;
125 }
126
127 self.pool
128 .record_direct_route(crate::timeout_sink::Site::DirectRouteEventGeneralWrite);
129 let unit_slot = if self.is_file_backed {
132 None
133 } else {
134 Some(crate::sql_bridge::acquire_in_memory_write_unit(&self.pool, op).await?)
135 };
136 let pool = Arc::clone(&self.pool);
137 let db = crate::timeout_sink::db_label(&pool);
138 let is_file_backed = self.is_file_backed;
139 tokio::task::spawn_blocking(move || {
140 let _unit_slot = unit_slot;
142 let result =
143 pool.execute_direct_transaction(StorageCapability::Events, op, move |conn| {
144 f(conn).map_err(|error| map_err(error, op))
145 });
146 if is_file_backed {
147 if let Err(error) = &result {
148 crate::timeout_sink::maybe_emit_busy_storage_error(
149 &db,
150 crate::timeout_sink::Site::StandaloneEvent,
151 error,
152 );
153 }
154 }
155 result
156 })
157 .await
158 .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
159 }
160
161 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
162 where
163 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
164 R: Send + 'static,
165 {
166 super::run_pooled_store_read(
167 Arc::clone(&self.pool),
168 StorageCapability::Events,
169 op,
170 move |conn| f(conn).map_err(|error| map_err(error, op)),
171 )
172 .await
173 }
174}
175
176fn substrate_from_str(s: &str) -> Result<SubstrateKind, rusqlite::Error> {
181 s.parse::<SubstrateKind>().map_err(|_| {
182 rusqlite::Error::FromSqlConversionFailure(
183 0,
184 rusqlite::types::Type::Text,
185 format!("unknown SubstrateKind: {s}").into(),
186 )
187 })
188}
189
190fn outcome_from_str(s: &str) -> Result<EventOutcome, rusqlite::Error> {
191 match s {
192 "success" => Ok(EventOutcome::Success),
193 "denied" => Ok(EventOutcome::Denied),
194 "error" => Ok(EventOutcome::Error),
195 other => Err(rusqlite::Error::FromSqlConversionFailure(
196 0,
197 rusqlite::types::Type::Text,
198 format!("unknown EventOutcome: {other}").into(),
199 )),
200 }
201}
202
203fn kind_from_str(s: &str) -> Result<EventKind, rusqlite::Error> {
204 s.parse::<EventKind>().map_err(|_| {
205 rusqlite::Error::FromSqlConversionFailure(
206 0,
207 rusqlite::types::Type::Text,
208 format!("unknown EventKind: {s}").into(),
209 )
210 })
211}
212
213fn referent_kind_from_str(s: &str) -> Result<ReferentKind, rusqlite::Error> {
214 match s {
215 "entity" => Ok(ReferentKind::Entity),
216 "note" => Ok(ReferentKind::Note),
217 "edge" => Ok(ReferentKind::Edge),
218 other => Err(rusqlite::Error::FromSqlConversionFailure(
219 0,
220 rusqlite::types::Type::Text,
221 format!("unknown ReferentKind: {other}").into(),
222 )),
223 }
224}
225
226fn observation_role_from_str(s: &str) -> Result<ObservationRole, rusqlite::Error> {
227 match s {
228 "candidate" => Ok(ObservationRole::Candidate),
229 "selected" => Ok(ObservationRole::Selected),
230 "target" => Ok(ObservationRole::Target),
231 "signal" => Ok(ObservationRole::Signal),
232 other => Err(rusqlite::Error::FromSqlConversionFailure(
233 0,
234 rusqlite::types::Type::Text,
235 format!("unknown ObservationRole: {other}").into(),
236 )),
237 }
238}
239
240fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
241 Uuid::parse_str(s).map_err(|e| {
242 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
243 })
244}
245
246fn read_event(row: &rusqlite::Row<'_>) -> Result<Event, rusqlite::Error> {
252 let id_str: String = row.get(0)?;
253 let namespace: String = row.get(1)?;
254 let verb: String = row.get(2)?;
255 let substrate_str: String = row.get(3)?;
256 let actor: String = row.get(4)?;
257 let kind_str: String = row.get(5)?;
258 let outcome_str: String = row.get(6)?;
259 let payload_str: String = row.get(7)?;
260 let payload_schema_version: i64 = row.get(8)?;
261 let profile_state_version: Option<i64> = row.get(9)?;
262 let duration_us: i64 = row.get(10)?;
263 let target_str: Option<String> = row.get(11)?;
264 let session_str: Option<String> = row.get(12)?;
265 let aggregate_kind: Option<String> = row.get(13)?;
266 let aggregate_str: Option<String> = row.get(14)?;
267 let created_at: i64 = row.get(15)?;
268 let op_index = row.get::<_, Option<u32>>(16)?;
269 let ref_resolution = row
270 .get::<_, Option<String>>(17)?
271 .map(|value| {
272 value
273 .parse::<khive_types::RefResolution>()
274 .map_err(|error| {
275 rusqlite::Error::FromSqlConversionFailure(
276 17,
277 rusqlite::types::Type::Text,
278 Box::new(error),
279 )
280 })
281 })
282 .transpose()?;
283 if op_index.is_some() != ref_resolution.is_some() {
284 return Err(rusqlite::Error::FromSqlConversionFailure(
285 16,
286 rusqlite::types::Type::Integer,
287 "event operation attribution must be present or absent together".into(),
288 ));
289 }
290
291 let id = parse_uuid(&id_str)?;
292 let substrate = substrate_from_str(&substrate_str)?;
293 let kind = kind_from_str(&kind_str)?;
294 let outcome = outcome_from_str(&outcome_str)?;
295 let payload: serde_json::Value = serde_json::from_str(&payload_str).map_err(|e| {
296 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
297 })?;
298 let target_id = target_str.as_deref().map(parse_uuid).transpose()?;
299 let session_id = session_str.as_deref().map(parse_uuid).transpose()?;
300 let aggregate_id = aggregate_str.as_deref().map(parse_uuid).transpose()?;
301 let payload_schema_version_u32: u32 = payload_schema_version.try_into().map_err(|_| {
302 rusqlite::Error::FromSqlConversionFailure(
303 8,
304 rusqlite::types::Type::Integer,
305 format!("payload_schema_version {payload_schema_version} out of u32 range").into(),
306 )
307 })?;
308 let profile_state_version_u64: Option<u64> = profile_state_version
309 .map(|v| {
310 u64::try_from(v).map_err(|_| {
311 rusqlite::Error::FromSqlConversionFailure(
312 9,
313 rusqlite::types::Type::Integer,
314 format!("profile_state_version {v} out of u64 range").into(),
315 )
316 })
317 })
318 .transpose()?;
319
320 Ok(Event {
321 id,
322 namespace,
323 verb,
324 substrate,
325 actor,
326 kind,
327 outcome,
328 payload,
329 payload_schema_version: payload_schema_version_u32,
330 profile_state_version: profile_state_version_u64,
331 duration_us,
332 target_id,
333 session_id,
334 aggregate_kind,
335 aggregate_id,
336 created_at,
337 op_index,
338 ref_resolution,
339 })
340}
341
342fn insert_event_with_observations(
347 conn: &rusqlite::Connection,
348 event: &Event,
349) -> Result<(), rusqlite::Error> {
350 validate_operation_pair(event)?;
351 let id_str = event.id.to_string();
352 let substrate_str = event.substrate.name().to_string();
353 let kind_str = event.kind.name().to_string();
354 let outcome_str = event.outcome.name().to_string();
355 let payload_str = event.payload.to_string();
356 let target_str = event.target_id.map(|u| u.to_string());
357 let session_str = event.session_id.map(|u| u.to_string());
358 let aggregate_str = event.aggregate_id.map(|u| u.to_string());
359 let profile_state_version = profile_state_version_to_sql(event)?;
360
361 conn.execute(
362 "INSERT INTO events \
363 (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
364 profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
365 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
366 rusqlite::params![
367 id_str,
368 &event.namespace,
369 &event.verb,
370 substrate_str,
371 &event.actor,
372 kind_str,
373 outcome_str,
374 payload_str,
375 event.payload_schema_version as i64,
376 profile_state_version,
377 event.duration_us,
378 target_str,
379 session_str,
380 &event.aggregate_kind,
381 aggregate_str,
382 event.created_at,
383 event.op_index,
384 event.ref_resolution.map(|value| value.name()),
385 ],
386 )?;
387
388 for observation in decode_event_observations(event)? {
389 conn.execute(
390 "INSERT INTO event_observations \
391 (event_id, entity_id, referent_kind, role, position) \
392 VALUES (?1, ?2, ?3, ?4, ?5)",
393 rusqlite::params![
394 observation.event_id.to_string(),
395 observation.entity_id.to_string(),
396 observation.referent_kind.name(),
397 observation.role.name(),
398 observation.position as i64,
399 ],
400 )?;
401 }
402
403 Ok(())
404}
405
406pub fn append_event_in_transaction(
411 conn: &rusqlite::Connection,
412 event: &Event,
413) -> Result<(), rusqlite::Error> {
414 insert_event_with_observations(conn, event)
415}
416
417fn batch_append_events_dml(
425 conn: &rusqlite::Connection,
426 events: &[Event],
427 attempted: u64,
428) -> Result<BatchWriteSummary, rusqlite::Error> {
429 let mut affected = 0u64;
430 for event in events {
431 insert_event_with_observations(conn, event)?;
432 affected += 1;
433 }
434 Ok(BatchWriteSummary {
435 attempted,
436 affected,
437 ..BatchWriteSummary::default()
438 })
439}
440
441fn fetch_event_by_id(
442 conn: &rusqlite::Connection,
443 id: Uuid,
444) -> Result<Option<Event>, rusqlite::Error> {
445 let id_str = id.to_string();
446 let mut stmt = conn.prepare(
447 "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
448 payload_schema_version, profile_state_version, duration_us, target_id, \
449 session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
450 FROM events WHERE id = ?1",
451 )?;
452 let mut rows = stmt.query(rusqlite::params![id_str])?;
453 match rows.next()? {
454 Some(row) => Ok(Some(read_event(row)?)),
455 None => Ok(None),
456 }
457}
458
459fn fetch_event_observations(
466 conn: &rusqlite::Connection,
467 event_id: Uuid,
468) -> Result<Vec<EventObservation>, rusqlite::Error> {
469 let id_str = event_id.to_string();
470 let mut stmt = conn.prepare(
471 "SELECT event_id, entity_id, referent_kind, role, position \
472 FROM event_observations WHERE event_id = ?1 ORDER BY role, position",
473 )?;
474 let rows = stmt.query_map(rusqlite::params![id_str], |row| {
475 let event_id: String = row.get(0)?;
476 let entity_id: String = row.get(1)?;
477 let referent_kind: String = row.get(2)?;
478 let role: String = row.get(3)?;
479 let position: i64 = row.get(4)?;
480 Ok((event_id, entity_id, referent_kind, role, position))
481 })?;
482
483 let mut out = Vec::new();
484 for row in rows {
485 let (event_id, entity_id, referent_kind, role, position) = row?;
486 let position_u32: u32 = position.try_into().map_err(|_| {
487 rusqlite::Error::FromSqlConversionFailure(
488 4,
489 rusqlite::types::Type::Integer,
490 format!("position {position} out of u32 range").into(),
491 )
492 })?;
493 out.push(EventObservation {
494 event_id: parse_uuid(&event_id)?,
495 entity_id: parse_uuid(&entity_id)?,
496 referent_kind: referent_kind_from_str(&referent_kind)?,
497 role: observation_role_from_str(&role)?,
498 position: position_u32,
499 });
500 }
501 Ok(out)
502}
503
504fn idempotent_batch_dml(
515 conn: &rusqlite::Connection,
516 events: &[Event],
517) -> Result<IdempotentEventBatchResult, rusqlite::Error> {
518 let mut rows = Vec::with_capacity(events.len());
519 for event in events {
520 validate_operation_pair(event)?;
521 match fetch_event_by_id(conn, event.id)? {
522 None => {
523 insert_event_with_observations(conn, event)?;
524 rows.push(EventAppendDisposition::Inserted);
525 }
526 Some(existing) => {
527 let existing_observations = fetch_event_observations(conn, event.id)?;
528 let submitted_observations = decode_event_observations(event)?;
529 if existing == *event && existing_observations == submitted_observations {
530 rows.push(EventAppendDisposition::AlreadyPresentIdentical);
531 } else {
532 rows.push(EventAppendDisposition::IdentityConflict);
533 }
534 }
535 }
536 }
537 Ok(IdempotentEventBatchResult { rows })
538}
539
540pub fn event_insert_statements(event: &Event) -> Result<Vec<SqlStatement>, rusqlite::Error> {
551 validate_operation_pair(event)?;
552 let id_str = event.id.to_string();
553 let substrate_str = event.substrate.name().to_string();
554 let kind_str = event.kind.name().to_string();
555 let outcome_str = event.outcome.name().to_string();
556 let payload_str = event.payload.to_string();
557 let target_str = event.target_id.map(|u| u.to_string());
558 let session_str = event.session_id.map(|u| u.to_string());
559 let aggregate_str = event.aggregate_id.map(|u| u.to_string());
560 let profile_state_version = profile_state_version_to_sql(event)?;
561
562 let mut statements = vec![SqlStatement {
563 sql: "INSERT INTO events \
564 (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
565 profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
566 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"
567 .into(),
568 params: vec![
569 SqlValue::Text(id_str),
570 SqlValue::Text(event.namespace.clone()),
571 SqlValue::Text(event.verb.clone()),
572 SqlValue::Text(substrate_str),
573 SqlValue::Text(event.actor.clone()),
574 SqlValue::Text(kind_str),
575 SqlValue::Text(outcome_str),
576 SqlValue::Text(payload_str),
577 SqlValue::Integer(event.payload_schema_version as i64),
578 profile_state_version
579 .map(SqlValue::Integer)
580 .unwrap_or(SqlValue::Null),
581 SqlValue::Integer(event.duration_us),
582 target_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
583 session_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
584 event
585 .aggregate_kind
586 .clone()
587 .map(SqlValue::Text)
588 .unwrap_or(SqlValue::Null),
589 aggregate_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
590 SqlValue::Integer(event.created_at),
591 event.op_index.map(|value| SqlValue::Integer(i64::from(value))).unwrap_or(SqlValue::Null),
592 event.ref_resolution.map(|value| SqlValue::Text(value.name().into())).unwrap_or(SqlValue::Null),
593 ],
594 label: Some("event_insert_on_writer".into()),
595 }];
596
597 for observation in decode_event_observations(event)? {
598 statements.push(SqlStatement {
599 sql: "INSERT INTO event_observations \
600 (event_id, entity_id, referent_kind, role, position) \
601 VALUES (?1, ?2, ?3, ?4, ?5)"
602 .into(),
603 params: vec![
604 SqlValue::Text(observation.event_id.to_string()),
605 SqlValue::Text(observation.entity_id.to_string()),
606 SqlValue::Text(observation.referent_kind.name().to_string()),
607 SqlValue::Text(observation.role.name().to_string()),
608 SqlValue::Integer(observation.position as i64),
609 ],
610 label: Some("event_observation_insert_on_writer".into()),
611 });
612 }
613
614 Ok(statements)
615}
616
617fn validate_operation_pair(event: &Event) -> Result<(), rusqlite::Error> {
618 if event.op_index.is_some() != event.ref_resolution.is_some() {
619 return Err(rusqlite::Error::ToSqlConversionFailure(
620 "event operation attribution must be present or absent together".into(),
621 ));
622 }
623 profile_state_version_to_sql(event)?;
624 Ok(())
625}
626
627fn profile_state_version_to_sql(event: &Event) -> Result<Option<i64>, rusqlite::Error> {
628 event
629 .profile_state_version
630 .map(|version| {
631 i64::try_from(version).map_err(|_| {
632 rusqlite::Error::ToSqlConversionFailure(
633 format!("profile_state_version {version} exceeds i64::MAX").into(),
634 )
635 })
636 })
637 .transpose()
638}
639
640pub fn hard_delete_lineage_warning_statements(
652 namespace: &str,
653 actor: &str,
654 target_id: Uuid,
655 substrate: SubstrateKind,
656) -> Vec<SqlStatement> {
657 const WARNINGS: [(&str, &str); 5] = [
658 ("derived_from", "provenance_loss"),
659 ("supersedes", "replacement_lineage_loss"),
660 ("precedes", "temporal_sequence_loss"),
661 ("supports", "evidential_link_loss"),
662 ("refutes", "evidential_link_loss"),
663 ];
664
665 let target_id = target_id.to_string();
666 let created_at = chrono::Utc::now().timestamp_micros();
667 let operation = khive_storage::operation_context::current_operation_attribution();
668 WARNINGS
669 .into_iter()
670 .map(|(relation, warning)| SqlStatement {
671 sql: format!(
672 "INSERT INTO events \
673 (id, namespace, verb, substrate, actor, kind, outcome, payload, \
674 payload_schema_version, profile_state_version, duration_us, target_id, \
675 session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
676 SELECT ?1, ?2, 'delete', ?3, ?4, ?5, ?6, \
677 json_object( \
678 'severity', 'warning', \
679 'warning', ?7, \
680 'deleted_id', ?8, \
681 'relation', ?9, \
682 'edge_count', count(*), \
683 'edges', json_group_array(json_object( \
684 'id', incident.id, \
685 'namespace', incident.namespace, \
686 'source_id', incident.source_id, \
687 'target_id', incident.target_id, \
688 'deleted_at', incident.deleted_at)) \
689 ), \
690 1, NULL, 0, ?8, NULL, NULL, NULL, ?10, ?11, ?12 \
691 FROM ( {cte} \
692 SELECT e.id AS id, e.namespace AS namespace, e.source_id AS source_id, \
693 e.target_id AS target_id, e.deleted_at AS deleted_at \
694 FROM edge_ns CROSS JOIN graph_edges AS e \
695 ON e.namespace = edge_ns.namespace AND e.source_id = ?8 AND e.relation = ?9 \
696 UNION ALL \
697 SELECT e.id AS id, e.namespace AS namespace, e.source_id AS source_id, \
698 e.target_id AS target_id, e.deleted_at AS deleted_at \
699 FROM edge_ns CROSS JOIN graph_edges AS e \
700 ON e.namespace = edge_ns.namespace AND e.target_id = ?8 AND e.relation = ?9 \
701 WHERE e.source_id <> ?8 \
702 ORDER BY namespace, id \
703 ) AS incident \
704 HAVING count(*) > 0",
705 cte = super::graph::EDGE_NAMESPACES_CTE
706 ),
707 params: vec![
708 SqlValue::Text(Uuid::new_v4().to_string()),
709 SqlValue::Text(namespace.to_string()),
710 SqlValue::Text(substrate.name().to_string()),
711 SqlValue::Text(actor.to_string()),
712 SqlValue::Text(EventKind::Audit.name().to_string()),
713 SqlValue::Text(EventOutcome::Success.name().to_string()),
714 SqlValue::Text(warning.to_string()),
715 SqlValue::Text(target_id.clone()),
716 SqlValue::Text(relation.to_string()),
717 SqlValue::Integer(created_at),
718 operation
719 .map(|value| SqlValue::Integer(i64::from(value.op_index)))
720 .unwrap_or(SqlValue::Null),
721 operation
722 .map(|value| SqlValue::Text(value.ref_resolution.name().into()))
723 .unwrap_or(SqlValue::Null),
724 ],
725 label: Some(format!("hard-delete-{relation}-warning")),
726 })
727 .collect()
728}
729
730pub async fn append_event_on_writer(
749 writer: &mut dyn SqlWriter,
750 event: &Event,
751) -> Result<(), StorageError> {
752 let statements =
753 event_insert_statements(event).map_err(|e| map_err(e, "decode_event_observations"))?;
754 for statement in statements {
755 writer.execute(statement).await?;
756 }
757 Ok(())
758}
759
760fn decode_event_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
761 match event.kind {
762 EventKind::RerankExecuted => decode_rerank_observations(event),
763 EventKind::RecallExecuted => decode_recall_observations(event),
764 EventKind::SearchExecuted => decode_search_observations(event),
765 EventKind::LinkCreated => decode_link_observations(event),
766 EventKind::EdgeUpdated | EventKind::EdgeDeleted => decode_edge_target_observation(event),
767 EventKind::EntityCreated
768 | EventKind::EntityUpdated
769 | EventKind::EntityDeleted
770 | EventKind::NoteCreated
771 | EventKind::NoteUpdated
772 | EventKind::NoteDeleted
773 | EventKind::TaskTransitioned => decode_target_observation(event),
774 EventKind::FeedbackExplicit => decode_signal_observation(event),
775 _ => Ok(Vec::new()),
776 }
777}
778
779fn payload_uuid_array(event: &Event, field: &'static str) -> Result<Vec<Uuid>, rusqlite::Error> {
780 Ok(payload_uuid_array_opt(event, field)?.unwrap_or_default())
781}
782
783fn payload_uuid_array_opt(
790 event: &Event,
791 field: &'static str,
792) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
793 let Some(values) = event.payload.get(field) else {
794 return Ok(None);
795 };
796 let Some(array) = values.as_array() else {
797 return Err(invalid_payload(event.kind, field, "expected array"));
798 };
799
800 array
801 .iter()
802 .map(|value| {
803 value
804 .as_str()
805 .ok_or_else(|| invalid_payload(event.kind, field, "expected UUID string"))
806 .and_then(|s| Uuid::parse_str(s).map_err(|e| invalid_payload(event.kind, field, e)))
807 })
808 .collect::<Result<Vec<_>, _>>()
809 .map(Some)
810}
811
812fn payload_final_scores_uuid_array_opt(
820 event: &Event,
821 field: &'static str,
822) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
823 let Some(values) = event.payload.get(field) else {
824 return Ok(None);
825 };
826 let tuples: Vec<(khive_types::Id128, f32)> = serde_json::from_value(values.clone())
827 .map_err(|e| invalid_payload(event.kind, field, e))?;
828 tuples
829 .into_iter()
830 .map(|(id, score)| {
831 if !score.is_finite() {
832 return Err(invalid_payload(event.kind, field, "score is not finite"));
833 }
834 Ok(Uuid::from_bytes(*id.as_bytes()))
835 })
836 .collect::<Result<Vec<_>, _>>()
837 .map(Some)
838}
839
840fn payload_reranked_uuid_array_opt(
846 event: &Event,
847 field: &'static str,
848) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
849 let Some(values) = event.payload.get(field) else {
850 return Ok(None);
851 };
852 let tuples: Vec<(khive_types::Id128, Vec<(String, f32)>)> =
853 serde_json::from_value(values.clone())
854 .map_err(|e| invalid_payload(event.kind, field, e))?;
855 tuples
856 .into_iter()
857 .map(|(id, scores)| {
858 if !scores.iter().all(|(_, s)| s.is_finite()) {
859 return Err(invalid_payload(event.kind, field, "score is not finite"));
860 }
861 Ok(Uuid::from_bytes(*id.as_bytes()))
862 })
863 .collect::<Result<Vec<_>, _>>()
864 .map(Some)
865}
866
867fn payload_uuid(event: &Event, field: &'static str) -> Result<Option<Uuid>, rusqlite::Error> {
868 let Some(value) = event.payload.get(field) else {
869 return Ok(None);
870 };
871 let Some(s) = value.as_str() else {
872 return Err(invalid_payload(event.kind, field, "expected UUID string"));
873 };
874 Uuid::parse_str(s)
875 .map(Some)
876 .map_err(|e| invalid_payload(event.kind, field, e))
877}
878
879fn decode_candidate_observations(
880 event: &Event,
881 referent_kind: ReferentKind,
882) -> Result<Vec<EventObservation>, rusqlite::Error> {
883 let mut rows = Vec::new();
884
885 for (position, entity_id) in payload_uuid_array(event, "candidates")?
886 .into_iter()
887 .enumerate()
888 {
889 let position_u32 = u32::try_from(position).map_err(|_| {
890 invalid_payload(
891 event.kind,
892 "candidates[position]",
893 "position out of u32 range",
894 )
895 })?;
896 rows.push(EventObservation {
897 event_id: event.id,
898 entity_id,
899 referent_kind,
900 role: ObservationRole::Candidate,
901 position: position_u32,
902 });
903 }
904
905 Ok(rows)
906}
907
908fn push_selected_observations(
909 event: &Event,
910 selected: Vec<Uuid>,
911 referent_kind: ReferentKind,
912 rows: &mut Vec<EventObservation>,
913) -> Result<(), rusqlite::Error> {
914 for (position, entity_id) in selected.into_iter().enumerate() {
915 let position_u32 = u32::try_from(position).map_err(|_| {
916 invalid_payload(
917 event.kind,
918 "selected[position]",
919 "position out of u32 range",
920 )
921 })?;
922 rows.push(EventObservation {
923 event_id: event.id,
924 entity_id,
925 referent_kind,
926 role: ObservationRole::Selected,
927 position: position_u32,
928 });
929 }
930 Ok(())
931}
932
933fn decode_recall_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
935 let mut rows = decode_candidate_observations(event, ReferentKind::Note)?;
936 let selected = payload_uuid_array_opt(event, "selected")?.unwrap_or_default();
937 push_selected_observations(event, selected, ReferentKind::Note, &mut rows)?;
938 Ok(rows)
939}
940
941fn decode_search_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
945 if event.payload.as_object().is_none() {
946 return Err(invalid_payload(event.kind, "payload", "expected object"));
947 }
948 let referent_kind = match event.payload.get("result_kind") {
949 Some(serde_json::Value::String(kind)) if kind == "entity" => ReferentKind::Entity,
950 Some(serde_json::Value::String(kind)) if kind == "note" => ReferentKind::Note,
951 Some(serde_json::Value::String(_)) => {
952 return Err(invalid_payload(
953 event.kind,
954 "result_kind",
955 "expected \"entity\" or \"note\"",
956 ));
957 }
958 Some(_) => {
959 return Err(invalid_payload(
960 event.kind,
961 "result_kind",
962 "expected string \"entity\" or \"note\"",
963 ));
964 }
965 None => ReferentKind::Note,
966 };
967 let mut rows = decode_candidate_observations(event, referent_kind)?;
968 let selected = payload_uuid_array_opt(event, "selected")?.unwrap_or_default();
969 push_selected_observations(event, selected, referent_kind, &mut rows)?;
970 Ok(rows)
971}
972
973fn decode_rerank_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
983 let mut rows = decode_candidate_observations(event, ReferentKind::Note)?;
984 let selected = payload_final_scores_uuid_array_opt(event, "final_scores")?
985 .or(payload_reranked_uuid_array_opt(event, "reranked")?)
986 .unwrap_or_default();
987 push_selected_observations(event, selected, ReferentKind::Note, &mut rows)?;
988
989 Ok(rows)
990}
991
992fn decode_link_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
993 let mut rows = Vec::new();
994 let source_kind = payload_link_referent_kind(event, "source_kind")?;
995 let target_kind = payload_link_referent_kind(event, "target_kind")?;
996 if let Some(source) = payload_uuid(event, "source_id")? {
997 if let Some(referent_kind) = source_kind {
998 rows.push(EventObservation {
999 event_id: event.id,
1000 entity_id: source,
1001 referent_kind,
1002 role: ObservationRole::Target,
1003 position: 0,
1004 });
1005 }
1006 }
1007 if let Some(target) = payload_uuid(event, "target_id")? {
1008 if let Some(referent_kind) = target_kind {
1009 rows.push(EventObservation {
1010 event_id: event.id,
1011 entity_id: target,
1012 referent_kind,
1013 role: ObservationRole::Target,
1014 position: 1,
1015 });
1016 }
1017 }
1018 if let Some(edge_id) = event.target_id.or(payload_uuid(event, "id")?) {
1019 rows.push(EventObservation {
1020 event_id: event.id,
1021 entity_id: edge_id,
1022 referent_kind: ReferentKind::Edge,
1023 role: ObservationRole::Target,
1024 position: 2,
1025 });
1026 }
1027 Ok(rows)
1028}
1029
1030fn payload_link_referent_kind(
1031 event: &Event,
1032 field: &'static str,
1033) -> Result<Option<ReferentKind>, rusqlite::Error> {
1034 match event.payload.get(field) {
1035 None => Ok(Some(ReferentKind::Entity)),
1036 Some(value) => match value.as_str() {
1037 Some("entity") => Ok(Some(ReferentKind::Entity)),
1038 Some("note") => Ok(Some(ReferentKind::Note)),
1039 Some("edge") => Ok(Some(ReferentKind::Edge)),
1040 Some("event") => Ok(None),
1041 _ => Err(invalid_payload(
1042 event.kind,
1043 field,
1044 "expected \"entity\", \"note\", \"edge\", or \"event\"",
1045 )),
1046 },
1047 }
1048}
1049
1050fn decode_edge_target_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1051 let Some(edge_id) = event.target_id.or(payload_uuid(event, "id")?) else {
1052 return Ok(Vec::new());
1053 };
1054 Ok(vec![EventObservation {
1055 event_id: event.id,
1056 entity_id: edge_id,
1057 referent_kind: ReferentKind::Edge,
1058 role: ObservationRole::Target,
1059 position: 0,
1060 }])
1061}
1062
1063fn decode_target_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1064 let Some(entity_id) = event.target_id.or(payload_uuid(event, "target_id")?) else {
1065 return Ok(Vec::new());
1066 };
1067 Ok(vec![EventObservation {
1068 event_id: event.id,
1069 entity_id,
1070 referent_kind: if event.substrate == SubstrateKind::Note {
1071 ReferentKind::Note
1072 } else {
1073 ReferentKind::Entity
1074 },
1075 role: ObservationRole::Target,
1076 position: 0,
1077 }])
1078}
1079
1080fn decode_signal_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1081 let Some(entity_id) = event.target_id else {
1082 return Ok(Vec::new());
1083 };
1084 Ok(vec![EventObservation {
1085 event_id: event.id,
1086 entity_id,
1087 referent_kind: if event.substrate == SubstrateKind::Note {
1093 ReferentKind::Note
1094 } else {
1095 ReferentKind::Entity
1096 },
1097 role: ObservationRole::Signal,
1098 position: 0,
1099 }])
1100}
1101
1102fn invalid_payload(
1103 kind: EventKind,
1104 field: &'static str,
1105 reason: impl std::fmt::Display,
1106) -> rusqlite::Error {
1107 rusqlite::Error::ToSqlConversionFailure(
1108 format!("invalid payload for {}.{field}: {reason}", kind.name()).into(),
1109 )
1110}
1111
1112fn build_event_filter_sql(
1117 conn: &rusqlite::Connection,
1118 default_namespace: &str,
1119 filter: &EventFilter,
1120) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1121 reject_missing_event_filter_schema(conn, filter)?;
1122
1123 let mut conditions: Vec<String> = Vec::new();
1124 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1125
1126 params.push(Box::new(default_namespace.to_string()));
1127 let namespace_term = if filter.ids.is_empty() {
1135 "namespace"
1136 } else {
1137 "+namespace"
1138 };
1139 conditions.push(format!("{namespace_term} = ?{}", params.len()));
1140
1141 push_in_clause(
1142 &mut conditions,
1143 &mut params,
1144 "id",
1145 filter.ids.iter().map(Uuid::to_string),
1146 );
1147 push_in_clause(
1148 &mut conditions,
1149 &mut params,
1150 "kind",
1151 filter.kinds.iter().map(|kind| kind.name().to_string()),
1152 );
1153 push_in_clause(
1154 &mut conditions,
1155 &mut params,
1156 "verb",
1157 filter.verbs.iter().cloned(),
1158 );
1159 push_in_clause(
1160 &mut conditions,
1161 &mut params,
1162 "substrate",
1163 filter.substrates.iter().map(|s| s.name().to_string()),
1164 );
1165 push_in_clause(
1166 &mut conditions,
1167 &mut params,
1168 "actor",
1169 filter.actors.iter().cloned(),
1170 );
1171
1172 if let Some(outcome) = filter.outcome {
1173 params.push(Box::new(outcome.name().to_string()));
1174 conditions.push(format!("outcome = ?{}", params.len()));
1175 }
1176 super::append_json_equalities(
1177 &mut conditions,
1178 &mut params,
1179 "payload",
1180 &filter.payload_equalities,
1181 );
1182
1183 if let Some(after) = filter.after {
1184 params.push(Box::new(after));
1185 conditions.push(format!("created_at > ?{}", params.len()));
1186 }
1187
1188 if let Some(before) = filter.before {
1189 params.push(Box::new(before));
1190 conditions.push(format!("created_at < ?{}", params.len()));
1191 }
1192
1193 if let Some(target_id) = filter.target_id {
1194 params.push(Box::new(target_id.to_string()));
1195 conditions.push(format!("target_id = ?{}", params.len()));
1196 }
1197
1198 if let Some(session_id) = filter.session_id {
1199 params.push(Box::new(session_id.to_string()));
1200 conditions.push(format!("session_id = ?{}", params.len()));
1201 }
1202
1203 push_observation_exists(&mut conditions, &mut params, None, &filter.observed);
1204 push_observation_exists(
1205 &mut conditions,
1206 &mut params,
1207 Some("selected"),
1208 &filter.selected,
1209 );
1210
1211 if let Some(proposal_id) = filter.payload_proposal_id {
1212 params.push(Box::new(proposal_id.to_string()));
1213 conditions.push(format!(
1214 "json_extract(payload, '$.proposal_id') = ?{}",
1215 params.len()
1216 ));
1217 }
1218
1219 let clause = format!(" WHERE {}", conditions.join(" AND "));
1220 Ok((clause, params))
1221}
1222
1223fn query_events_sql(where_clause: &str, limit_idx: usize, offset_idx: usize) -> String {
1224 format!(
1225 "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
1226 payload_schema_version, profile_state_version, duration_us, target_id, \
1227 session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
1228 FROM events{where_clause} ORDER BY created_at DESC, id DESC LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1229 )
1230}
1231
1232fn push_in_clause<I>(
1233 conditions: &mut Vec<String>,
1234 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1235 column: &'static str,
1236 values: I,
1237) where
1238 I: IntoIterator<Item = String>,
1239{
1240 let placeholders: Vec<String> = values
1241 .into_iter()
1242 .map(|value| {
1243 params.push(Box::new(value));
1244 format!("?{}", params.len())
1245 })
1246 .collect();
1247 if !placeholders.is_empty() {
1248 conditions.push(format!("{column} IN ({})", placeholders.join(",")));
1249 }
1250}
1251
1252fn push_observation_exists(
1253 conditions: &mut Vec<String>,
1254 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1255 role: Option<&'static str>,
1256 entity_ids: &[Uuid],
1257) {
1258 if entity_ids.is_empty() {
1259 return;
1260 }
1261 let placeholders: Vec<String> = entity_ids
1262 .iter()
1263 .map(|id| {
1264 params.push(Box::new(id.to_string()));
1265 format!("?{}", params.len())
1266 })
1267 .collect();
1268 let role_clause = role
1269 .map(|role| format!(" AND o.role = '{role}'"))
1270 .unwrap_or_default();
1271 conditions.push(format!(
1272 "EXISTS (SELECT 1 FROM event_observations o \
1273 WHERE o.event_id = events.id{role_clause} AND o.entity_id IN ({}))",
1274 placeholders.join(",")
1275 ));
1276}
1277
1278fn reject_missing_event_filter_schema(
1279 conn: &rusqlite::Connection,
1280 filter: &EventFilter,
1281) -> Result<(), rusqlite::Error> {
1282 if filter.target_id.is_some() && !has_column(conn, "events", "target_id")? {
1283 return Err(schema_absent("events.target_id"));
1284 }
1285 if filter.session_id.is_some() && !has_column(conn, "events", "session_id")? {
1286 return Err(schema_absent("events.session_id"));
1287 }
1288 if (!filter.observed.is_empty() || !filter.selected.is_empty())
1289 && !has_table(conn, "event_observations")?
1290 {
1291 return Err(schema_absent("event_observations"));
1292 }
1293 if filter.payload_proposal_id.is_some() && !has_column(conn, "events", "payload")? {
1294 return Err(schema_absent("events.payload"));
1295 }
1296 Ok(())
1297}
1298
1299fn has_table(conn: &rusqlite::Connection, table: &'static str) -> Result<bool, rusqlite::Error> {
1300 conn.query_row(
1301 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
1302 [table],
1303 |row| row.get(0),
1304 )
1305}
1306
1307fn has_column(
1308 conn: &rusqlite::Connection,
1309 table: &'static str,
1310 column: &'static str,
1311) -> Result<bool, rusqlite::Error> {
1312 conn.query_row(
1313 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
1314 rusqlite::params![table, column],
1315 |row| row.get(0),
1316 )
1317}
1318
1319fn schema_absent(name: &'static str) -> rusqlite::Error {
1320 rusqlite::Error::ToSqlConversionFailure(
1321 format!("event filter requires missing schema element {name}; run migrations").into(),
1322 )
1323}
1324
1325#[async_trait]
1330impl EventStore for SqlEventStore {
1331 async fn append_event(&self, event: Event) -> Result<(), StorageError> {
1332 if let Some(writer_task) = self.current_writer_task("append_event")? {
1343 let result = writer_task
1344 .send_bounded_at(
1345 move |conn| {
1346 insert_event_with_observations(conn, &event)
1347 .map_err(|e| map_err(e, "append_event"))
1348 },
1349 "append_event",
1350 )
1351 .await;
1352 khive_storage::usage::account_event_write(result.as_ref().map(|()| 1));
1353 return result;
1354 }
1355
1356 let result = self
1357 .with_writer("append_event", move |conn| {
1358 insert_event_with_observations(conn, &event)
1359 })
1360 .await;
1361 #[cfg(test)]
1362 let result = self.after_fallback_write_for_test(result);
1363 khive_storage::usage::account_event_write(result.as_ref().map(|()| 1));
1364 result
1365 }
1366
1367 async fn append_events(&self, events: Vec<Event>) -> Result<BatchWriteSummary, StorageError> {
1368 let attempted = events.len() as u64;
1369
1370 if let Some(writer_task) = self.current_writer_task("append_events")? {
1376 let result = writer_task
1377 .send_bounded_at(
1378 move |conn| {
1379 batch_append_events_dml(conn, &events, attempted)
1380 .map_err(|e| map_err(e, "append_events"))
1381 },
1382 "append_events",
1383 )
1384 .await;
1385 khive_storage::usage::account_event_write(
1386 result.as_ref().map(|summary| summary.affected),
1387 );
1388 return result;
1389 }
1390
1391 let result = self
1392 .with_writer("append_events", move |conn| {
1393 batch_append_events_dml(conn, &events, attempted)
1394 })
1395 .await;
1396 #[cfg(test)]
1397 let result = self.after_fallback_write_for_test(result);
1398 khive_storage::usage::account_event_write(result.as_ref().map(|summary| summary.affected));
1399 result
1400 }
1401
1402 async fn get_event(&self, id: Uuid) -> Result<Option<Event>, StorageError> {
1403 let namespace = self.namespace.clone();
1404 let id_str = id.to_string();
1405
1406 self.with_reader("get_event", move |conn| {
1407 let mut stmt = conn.prepare(
1408 "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
1409 payload_schema_version, profile_state_version, duration_us, target_id, \
1410 session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
1411 FROM events WHERE namespace = ?1 AND id = ?2",
1412 )?;
1413 let mut rows = stmt.query(rusqlite::params![namespace, id_str])?;
1414 match rows.next()? {
1415 Some(row) => Ok(Some(read_event(row)?)),
1416 None => Ok(None),
1417 }
1418 })
1419 .await
1420 }
1421
1422 async fn query_events(
1423 &self,
1424 filter: EventFilter,
1425 page: PageRequest,
1426 ) -> Result<Page<Event>, StorageError> {
1427 super::validate_json_equality_paths(
1428 &filter.payload_equalities,
1429 StorageCapability::Events,
1430 "query_events",
1431 )?;
1432 let namespace = self.namespace.clone();
1433 let limit_i64 = i64::from(page.limit);
1434 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1435 capability: StorageCapability::Events,
1436 operation: "query_events".into(),
1437 message: format!(
1438 "PageRequest: offset must be <= i64::MAX, got {}",
1439 page.offset
1440 ),
1441 })?;
1442
1443 self.with_reader("query_events", move |conn| {
1444 let (where_clause, filter_params) = build_event_filter_sql(conn, &namespace, &filter)?;
1454
1455 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = filter_params;
1456 all_params.push(Box::new(limit_i64));
1457 all_params.push(Box::new(offset_i64));
1458
1459 let limit_idx = all_params.len() - 1;
1460 let offset_idx = all_params.len();
1461
1462 let data_sql = query_events_sql(&where_clause, limit_idx, offset_idx);
1463
1464 let mut stmt = conn.prepare(&data_sql)?;
1465 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1466 all_params.iter().map(|p| p.as_ref()).collect();
1467 let rows = stmt.query_map(param_refs.as_slice(), read_event)?;
1468
1469 let mut items = Vec::new();
1470 for row in rows {
1471 items.push(row?);
1472 }
1473
1474 Ok(Page { items, total: None })
1475 })
1476 .await
1477 }
1478
1479 async fn query_event_page(
1480 &self,
1481 query: khive_storage::event::EventPageQuery,
1482 ) -> Result<khive_storage::event::EventPageWindow, StorageError> {
1483 cursor::query_event_page(self, query).await
1484 }
1485
1486 async fn count_events(&self, filter: EventFilter) -> Result<u64, StorageError> {
1487 super::validate_json_equality_paths(
1488 &filter.payload_equalities,
1489 StorageCapability::Events,
1490 "count_events",
1491 )?;
1492 let namespace = self.namespace.clone();
1493
1494 self.with_reader("count_events", move |conn| {
1495 let (where_clause, params) = build_event_filter_sql(conn, &namespace, &filter)?;
1496 let sql = format!("SELECT COUNT(*) FROM events{}", where_clause);
1497 let mut stmt = conn.prepare(&sql)?;
1498 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1499 params.iter().map(|p| p.as_ref()).collect();
1500 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1501 Ok(count as u64)
1502 })
1503 .await
1504 }
1505
1506 async fn count_events_grouped(
1507 &self,
1508 filter: EventFilter,
1509 group_by: EventGroupBy,
1510 ) -> Result<BTreeMap<String, u64>, StorageError> {
1511 super::validate_json_equality_paths(
1512 &filter.payload_equalities,
1513 StorageCapability::Events,
1514 "count_events_grouped",
1515 )?;
1516 let namespace = self.namespace.clone();
1517 self.with_reader("count_events_grouped", move |conn| {
1518 let (where_clause, params) = build_event_filter_sql(conn, &namespace, &filter)?;
1519 let column = group_by.column();
1522 let sql =
1523 format!("SELECT {column}, COUNT(*) FROM events{where_clause} GROUP BY {column}");
1524 let mut stmt = conn.prepare(&sql)?;
1525 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1526 params.iter().map(|p| p.as_ref()).collect();
1527 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1528 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
1529 })?;
1530 rows.collect()
1531 })
1532 .await
1533 }
1534
1535 fn preflight_event(&self, event: &Event) -> Result<(), StorageError> {
1536 event_insert_statements(event)
1537 .map(|_| ())
1538 .map_err(|e| map_err(e, "preflight_event"))
1539 }
1540
1541 async fn append_events_idempotent(
1542 &self,
1543 events: Vec<Event>,
1544 ) -> Result<IdempotentEventBatchResult, StorageError> {
1545 if let Some(writer_task) = self.current_writer_task("append_events_idempotent")? {
1546 return writer_task
1547 .send_bounded_at(
1548 move |conn| {
1549 idempotent_batch_dml(conn, &events)
1550 .map_err(|e| map_err(e, "append_events_idempotent"))
1551 },
1552 "append_events_idempotent",
1553 )
1554 .await;
1555 }
1556
1557 self.with_writer("append_events_idempotent", move |conn| {
1558 idempotent_batch_dml(conn, &events)
1559 })
1560 .await
1561 }
1562
1563 fn supports_idempotent_audit_batch(&self) -> bool {
1564 true
1565 }
1566}
1567
1568const EVENTS_DDL: &str = include_str!("../../sql/events-ddl.sql");
1573const OPERATION_ATTRIBUTION_COLUMNS: &str =
1574 include_str!("../../sql/036-events-operation-attribution.sql");
1575
1576pub(crate) fn ensure_events_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1577 conn.execute_batch(EVENTS_DDL)?;
1578 ensure_operation_attribution_columns(conn)
1579}
1580
1581pub(crate) fn ensure_operation_attribution_columns(
1588 conn: &rusqlite::Connection,
1589) -> Result<(), rusqlite::Error> {
1590 match (
1591 has_column(conn, "events", "op_index")?,
1592 has_column(conn, "events", "ref_resolution")?,
1593 ) {
1594 (true, true) => Ok(()),
1595 (false, false) if conn.is_autocommit() => {
1596 let tx = conn.unchecked_transaction()?;
1597 tx.execute_batch(OPERATION_ATTRIBUTION_COLUMNS)?;
1598 tx.commit()
1599 }
1600 (false, false) => conn.execute_batch(OPERATION_ATTRIBUTION_COLUMNS),
1601 (op_index, ref_resolution) => Err(rusqlite::Error::ToSqlConversionFailure(
1602 format!(
1603 "events table has only one operation attribution column \
1604 (op_index={op_index}, ref_resolution={ref_resolution}); refusing to guess"
1605 )
1606 .into(),
1607 )),
1608 }
1609}
1610
1611#[cfg(test)]
1612#[path = "event_tests.rs"]
1613mod tests;
1614
1615#[cfg(test)]
1616#[path = "event_busy_tests.rs"]
1617mod direct_busy_tests;
1618
1619#[cfg(test)]
1620#[path = "event_fallback_usage_tests.rs"]
1621mod fallback_usage_tests;
1622
1623#[cfg(test)]
1624#[path = "event_plan_tests.rs"]
1625mod plan_tests;