use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::{DbError, Result};
use crate::graph::builder::AttributeMode;
use crate::temporal::replay::PAYLOAD_VERSION;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AsOf {
pub valid: Option<String>,
pub recorded: Option<String>,
}
impl AsOf {
pub fn now() -> Self {
Self::default()
}
pub fn valid_at(ts: impl Into<String>) -> Self {
Self {
valid: Some(ts.into()),
recorded: None,
}
}
pub fn recorded_at(ts: impl Into<String>) -> Self {
Self {
valid: None,
recorded: Some(ts.into()),
}
}
pub fn bitemporal(valid: impl Into<String>, recorded: impl Into<String>) -> Self {
Self {
valid: Some(valid.into()),
recorded: Some(recorded.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeAttributes {
pub id: String,
pub title: String,
pub content: String,
pub embedding_model: Option<String>,
}
use crate::util::limits::HYDRATE_CHUNK;
pub async fn query_as_of_edges(
conn: &libsql::Connection,
ts: &str,
) -> Result<Vec<(String, String, String, String, String)>> {
query_as_of_edges_on(conn, ts, None).await
}
pub async fn query_as_of_edges_on(
conn: &libsql::Connection,
ts: &str,
branch: Option<&str>,
) -> Result<Vec<(String, String, String, String, String)>> {
use crate::graph::lineage::{
ancestry_cte, churned_cte, lineage_shape, links_cut_cte, visible_cte, LineageShape,
};
let (sql, params): (String, Vec<libsql::Value>) =
match lineage_shape(conn, branch).await? {
LineageShape::Trunk => (
r#"
SELECT source_id, target_id, edge_type, valid_from, valid_to
FROM links_current
WHERE valid_from <= ?1 AND ?1 < valid_to
"#
.to_string(),
vec![ts.into()],
),
LineageShape::Resolved => (
format!(
"WITH RECURSIVE {},\n{},\n{},\n{}\n SELECT source_id, target_id, edge_type, valid_from, valid_to \
FROM visible WHERE valid_from <= ?1 AND ?1 < valid_to",
ancestry_cte(2, ""),
churned_cte(""),
links_cut_cte(""),
visible_cte("links_cut", ""),
),
vec![
ts.into(),
branch.unwrap_or(crate::schema::ddl::MAIN_BRANCH).into(),
],
),
};
let mut rows = conn.query(&sql, params).await?;
let mut edges = Vec::new();
while let Some(row) = rows.next().await? {
let src: String = row.get(0)?;
let tgt: String = row.get(1)?;
let edge_type: String = row.get(2)?;
let vf: String = row.get(3)?;
let vt: String = row.get(4)?;
edges.push((src, tgt, edge_type, vf, vt));
}
Ok(edges)
}
fn placeholders(first: usize, count: usize) -> String {
(first..first + count)
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ")
}
pub async fn hydrate_attributes(
conn: &libsql::Connection,
node_ids: &[String],
as_of: &AsOf,
mode: AttributeMode,
) -> Result<Vec<NodeAttributes>> {
if node_ids.is_empty() {
return Ok(Vec::new());
}
let found: HashMap<String, NodeAttributes> = match mode {
AttributeMode::Omit => return Ok(Vec::new()),
AttributeMode::Current => hydrate_current(conn, node_ids, None).await?,
AttributeMode::AtTime => match as_of.recorded.as_deref() {
None => hydrate_current(conn, node_ids, as_of.valid.as_deref()).await?,
Some(recorded) => {
hydrate_at_time(conn, node_ids, recorded, as_of.valid.as_deref()).await?
}
},
};
let mut out = Vec::with_capacity(found.len());
for id in node_ids {
if let Some(attrs) = found.get(id) {
out.push(attrs.clone());
}
}
Ok(out)
}
async fn hydrate_current(
conn: &libsql::Connection,
node_ids: &[String],
valid: Option<&str>,
) -> Result<HashMap<String, NodeAttributes>> {
let mut found = HashMap::new();
for chunk in node_ids.chunks(HYDRATE_CHUNK) {
let (first, valid_filter) = match valid {
Some(_) => (2, " AND valid_from <= ?1 AND ?1 < valid_to"),
None => (1, ""),
};
let sql = format!(
"SELECT id, title, content, embedding_model FROM concepts \
WHERE retired = 0{valid_filter} AND id IN ({})",
placeholders(first, chunk.len())
);
let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
if let Some(v) = valid {
params.push(libsql::Value::Text(v.to_string()));
}
params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
let mut rows = conn.query(&sql, params).await?;
while let Some(row) = rows.next().await? {
let id: String = row.get(0)?;
found.insert(
id.clone(),
NodeAttributes {
id,
title: row.get(1)?,
content: row.get(2)?,
embedding_model: row.get(3).ok(),
},
);
}
}
Ok(found)
}
async fn hydrate_at_time(
conn: &libsql::Connection,
node_ids: &[String],
ts: &str,
valid: Option<&str>,
) -> Result<HashMap<String, NodeAttributes>> {
if !crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
return Err(DbError::RecordedInstantUnreachable { ts: ts.to_string() });
}
let mut found = HashMap::new();
for chunk in node_ids.chunks(HYDRATE_CHUNK) {
let sql = format!(
r#"
SELECT entity_id, seq_id, payload FROM (
SELECT entity_id, seq_id, payload,
ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY seq_id DESC) as rn
FROM transaction_log
WHERE table_name = 'concepts'
AND recorded_at <= ?1
AND entity_id IN ({})
) WHERE rn = 1
"#,
placeholders(2, chunk.len())
);
let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
params.push(libsql::Value::Text(ts.to_string()));
params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
let mut rows = conn.query(&sql, params).await?;
while let Some(row) = rows.next().await? {
let id: String = row.get(0)?;
let seq_id: i64 = row.get(1)?;
let payload_str: String = row.get(2)?;
let payload: serde_json::Value =
serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
seq: seq_id,
reason: format!("Failed to parse payload JSON: {e}"),
})?;
let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
if v > PAYLOAD_VERSION as u64 {
return Err(DbError::PayloadVersion {
got: v as u8,
max: PAYLOAD_VERSION,
});
}
if payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0) != 0 {
continue;
}
if let Some(v) = valid {
let from = payload.get("valid_from").and_then(|s| s.as_str());
let to = payload.get("valid_to").and_then(|s| s.as_str());
if from.is_some_and(|f| f > v) || to.is_some_and(|t| t <= v) {
continue;
}
}
found.insert(
id.clone(),
NodeAttributes {
id,
title: payload
.get("title")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
content: payload
.get("content")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
embedding_model: payload
.get("embedding_model")
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
},
);
}
}
Ok(found)
}