1#![allow(deprecated)]
37
38use crate::error::MemoryError;
39use crate::types::TraceId;
40use serde::{Deserialize, Serialize};
41pub use stack_ids::EnvelopeId;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ImportEnvelope {
59 pub envelope_id: EnvelopeId,
61 pub schema_version: String,
63 pub content_digest: String,
65 pub source_authority: String,
67 pub trace_id: Option<TraceId>,
69 pub namespace: String,
71 pub records: Vec<ImportRecord>,
73}
74
75impl ImportEnvelope {
76 pub fn validate(&self) -> Result<(), MemoryError> {
78 if self.envelope_id.is_empty() {
79 return Err(MemoryError::ImportInvalid {
80 reason: "envelope_id must not be empty".into(),
81 });
82 }
83 if self.schema_version.is_empty() {
84 return Err(MemoryError::ImportInvalid {
85 reason: "schema_version must not be empty".into(),
86 });
87 }
88 if self.content_digest.is_empty() {
89 return Err(MemoryError::ImportInvalid {
90 reason: "content_digest must not be empty".into(),
91 });
92 }
93 if self.source_authority.is_empty() {
94 return Err(MemoryError::ImportInvalid {
95 reason: "source_authority must not be empty".into(),
96 });
97 }
98 if self.namespace.is_empty() {
99 return Err(MemoryError::ImportInvalid {
100 reason: "namespace must not be empty".into(),
101 });
102 }
103 if self.records.is_empty() {
104 return Err(MemoryError::ImportInvalid {
105 reason: "envelope must contain at least one record".into(),
106 });
107 }
108 for (i, record) in self.records.iter().enumerate() {
109 record.validate().map_err(|e| MemoryError::ImportInvalid {
110 reason: format!("record[{i}]: {e}"),
111 })?;
112 }
113 Ok(())
114 }
115
116 pub(crate) fn dedupe_key(&self) -> (&str, &str, &str) {
118 (
119 self.envelope_id.as_str(),
120 &self.schema_version,
121 &self.content_digest,
122 )
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "snake_case")]
129pub enum ImportRecord {
130 Fact {
132 content: String,
134 source: Option<String>,
136 metadata: Option<serde_json::Value>,
138 },
139 Episode {
141 document_id: String,
143 meta: crate::types::EpisodeMeta,
145 },
146}
147
148impl ImportRecord {
149 fn validate(&self) -> Result<(), MemoryError> {
150 match self {
151 ImportRecord::Fact { content, .. } => {
152 if content.is_empty() {
153 return Err(MemoryError::ImportInvalid {
154 reason: "fact content must not be empty".into(),
155 });
156 }
157 }
158 ImportRecord::Episode { document_id, .. } => {
159 if document_id.is_empty() {
160 return Err(MemoryError::ImportInvalid {
161 reason: "episode document_id must not be empty".into(),
162 });
163 }
164 }
165 }
166 Ok(())
167 }
168
169 pub fn content_text(&self) -> &str {
171 match self {
172 ImportRecord::Fact { content, .. } => content,
173 ImportRecord::Episode { meta, .. } => &meta.effect_type,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ImportStatus {
184 Complete,
186 AlreadyImported,
188 Aborted { reason: String },
190}
191
192impl ImportStatus {
193 pub fn as_str(&self) -> &str {
194 match self {
195 Self::Complete => "complete",
196 Self::AlreadyImported => "already_imported",
197 Self::Aborted { .. } => "aborted",
198 }
199 }
200
201 pub fn from_str_value(s: &str) -> Self {
202 match s {
203 "complete" => Self::Complete,
204 "already_imported" => Self::AlreadyImported,
205 s if s.starts_with("aborted:") => Self::Aborted {
206 reason: s.strip_prefix("aborted:").unwrap_or("").to_string(),
207 },
208 _ => Self::Aborted {
209 reason: format!("unknown status: {s}"),
210 },
211 }
212 }
213
214 pub(crate) fn to_db_string(&self) -> String {
216 match self {
217 Self::Complete => "complete".into(),
218 Self::AlreadyImported => "already_imported".into(),
219 Self::Aborted { reason } => format!("aborted:{reason}"),
220 }
221 }
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ImportReceipt {
227 pub envelope_id: EnvelopeId,
229 pub schema_version: String,
231 pub content_digest: String,
233 pub status: ImportStatus,
235 pub record_count: usize,
237 pub imported_at: String,
239 pub was_duplicate: bool,
241 pub trace_id: Option<TraceId>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum ImportProjectionFreshness {
253 Current,
255 Stale { last_import_at: String },
257 Superseded { superseded_by: EnvelopeId },
259 ImportFailed { error: String, attempted_at: String },
261 NeverImported,
263}
264
265pub(crate) const MIGRATION_V10: &str = r#"
269CREATE TABLE IF NOT EXISTS import_log (
270 envelope_id TEXT NOT NULL,
271 schema_version TEXT NOT NULL,
272 content_digest TEXT NOT NULL,
273 source_authority TEXT NOT NULL,
274 namespace TEXT NOT NULL,
275 trace_id TEXT,
276 record_count INTEGER NOT NULL,
277 status TEXT NOT NULL DEFAULT 'complete',
278 imported_at TEXT NOT NULL DEFAULT (datetime('now')),
279 PRIMARY KEY (envelope_id, schema_version, content_digest)
280);
281
282CREATE INDEX IF NOT EXISTS idx_import_log_namespace ON import_log(namespace);
283CREATE INDEX IF NOT EXISTS idx_import_log_imported_at ON import_log(imported_at DESC);
284CREATE INDEX IF NOT EXISTS idx_import_log_source ON import_log(source_authority);
285"#;
286
287pub(crate) fn check_import_exists(
289 conn: &rusqlite::Connection,
290 envelope_id: &str,
291 schema_version: &str,
292 content_digest: &str,
293) -> Result<bool, MemoryError> {
294 let count: i64 = conn
295 .query_row(
296 "SELECT COUNT(*) FROM import_log
297 WHERE envelope_id = ?1 AND schema_version = ?2 AND content_digest = ?3",
298 rusqlite::params![envelope_id, schema_version, content_digest],
299 |row| row.get(0),
300 )
301 .unwrap_or(0);
302 Ok(count > 0)
303}
304
305pub(crate) fn insert_import_log(
307 tx: &rusqlite::Transaction<'_>,
308 envelope: &ImportEnvelope,
309 status: &ImportStatus,
310 record_count: usize,
311) -> Result<(), MemoryError> {
312 tx.execute(
313 "INSERT OR REPLACE INTO import_log
314 (envelope_id, schema_version, content_digest, source_authority,
315 namespace, trace_id, record_count, status)
316 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
317 rusqlite::params![
318 envelope.envelope_id.as_str(),
319 envelope.schema_version,
320 envelope.content_digest,
321 envelope.source_authority,
322 envelope.namespace,
323 envelope.trace_id.as_ref().map(|t| t.as_str()),
324 record_count as i64,
325 status.to_db_string(),
326 ],
327 )?;
328 Ok(())
329}
330
331pub(crate) fn query_import_log(
333 conn: &rusqlite::Connection,
334 namespace: Option<&str>,
335 limit: usize,
336) -> Result<Vec<ImportReceipt>, MemoryError> {
337 let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(ns) = namespace {
338 (
339 "SELECT envelope_id, schema_version, content_digest, status,
340 record_count, imported_at, trace_id
341 FROM import_log
342 WHERE namespace = ?1
343 ORDER BY imported_at DESC
344 LIMIT ?2",
345 vec![
346 Box::new(ns.to_string()) as Box<dyn rusqlite::types::ToSql>,
347 Box::new(limit as i64),
348 ],
349 )
350 } else {
351 (
352 "SELECT envelope_id, schema_version, content_digest, status,
353 record_count, imported_at, trace_id
354 FROM import_log
355 ORDER BY imported_at DESC
356 LIMIT ?1",
357 vec![Box::new(limit as i64) as Box<dyn rusqlite::types::ToSql>],
358 )
359 };
360
361 let mut stmt = conn.prepare(sql)?;
362 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
363 let rows = stmt
364 .query_map(params_refs.as_slice(), |row| {
365 Ok((
366 row.get::<_, String>(0)?,
367 row.get::<_, String>(1)?,
368 row.get::<_, String>(2)?,
369 row.get::<_, String>(3)?,
370 row.get::<_, i64>(4)?,
371 row.get::<_, String>(5)?,
372 row.get::<_, Option<String>>(6)?,
373 ))
374 })?
375 .collect::<Result<Vec<_>, _>>()?;
376
377 Ok(rows
378 .into_iter()
379 .map(
380 |(
381 envelope_id,
382 schema_version,
383 content_digest,
384 status,
385 record_count,
386 imported_at,
387 trace_id,
388 )| {
389 let status_parsed = ImportStatus::from_str_value(&status);
390 let was_duplicate = matches!(status_parsed, ImportStatus::AlreadyImported);
391 ImportReceipt {
392 envelope_id: EnvelopeId(envelope_id),
393 schema_version,
394 content_digest,
395 status: status_parsed,
396 record_count: record_count as usize,
397 imported_at,
398 was_duplicate,
399 trace_id: trace_id.map(TraceId::new),
400 }
401 },
402 )
403 .collect())
404}
405
406pub(crate) fn last_import_at(
408 conn: &rusqlite::Connection,
409 namespace: &str,
410) -> Result<Option<String>, MemoryError> {
411 let v10: Option<String> = conn
415 .query_row(
416 "SELECT imported_at FROM import_log
417 WHERE namespace = ?1 AND status = 'complete'
418 ORDER BY imported_at DESC LIMIT 1",
419 rusqlite::params![namespace],
420 |row| row.get(0),
421 )
422 .ok();
423
424 let v11: Option<String> = conn
426 .query_row(
427 "SELECT imported_at FROM projection_import_log
428 WHERE scope_namespace = ?1 AND status = 'complete'
429 ORDER BY imported_at DESC LIMIT 1",
430 rusqlite::params![namespace],
431 |row| row.get(0),
432 )
433 .ok();
434
435 let result = match (v10, v11) {
437 (Some(a), Some(b)) => Some(if a >= b { a } else { b }),
438 (Some(a), None) => Some(a),
439 (None, Some(b)) => Some(b),
440 (None, None) => None,
441 };
442 Ok(result)
443}