1use async_trait::async_trait;
2use chrono::{DateTime, TimeDelta, Utc};
3use minco_plugin_audit::{
4 AuditAppendReport, AuditCursor, AuditJournalEntry, AuditJournalStatus, AuditJournalStore,
5 AuditLedgerError, AuditLedgerWriter, AuditLifecyclePolicy, AuditPage, AuditQuery, AuditReader,
6 AuditRecordV2, AuditSegmentState, AuditSegmentStatus, AuditStorageHealth,
7 AuditStorageInspector, AuditStorageSnapshot, evaluate_storage_health,
8};
9use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, Transaction};
10use std::collections::BTreeMap;
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
14pub struct SqliteAuditJournal {
15 pool: SqlitePool,
16}
17
18impl SqliteAuditJournal {
19 pub const fn new(pool: SqlitePool) -> Self {
20 Self { pool }
21 }
22
23 pub async fn enqueue_in(
24 &self,
25 transaction: &mut Transaction<'_, Sqlite>,
26 entry: AuditJournalEntry,
27 ) -> Result<(), AuditLedgerError> {
28 validate_pending_entry(&entry)?;
29 let record =
30 serde_json::to_string(&entry.record).map_err(|_| AuditLedgerError::Encoding)?;
31 let encoded_bytes = i64::try_from(entry.encoded_bytes)
32 .map_err(|_| AuditLedgerError::InvalidJournalEntry)?;
33 let result = sqlx::query(
34 "INSERT INTO minco_audit_journal
35 (event_id, occurred_at, record, encoded_bytes, status, attempt_count,
36 available_at, claimed_by, claim_expires_at, failure_code)
37 VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
38 ON CONFLICT(event_id) DO NOTHING",
39 )
40 .bind(entry.record.event_id)
41 .bind(entry.record.occurred_at)
42 .bind(&record)
43 .bind(encoded_bytes)
44 .bind(i64::from(entry.attempt_count))
45 .bind(entry.available_at)
46 .bind(entry.claimed_by)
47 .bind(entry.claim_expires_at)
48 .bind(entry.failure_code)
49 .execute(&mut **transaction)
50 .await
51 .map_err(infrastructure)?;
52 if result.rows_affected() == 1 {
53 return Ok(());
54 }
55 let existing: String =
56 sqlx::query_scalar("SELECT record FROM minco_audit_journal WHERE event_id = ?")
57 .bind(entry.record.event_id)
58 .fetch_one(&mut **transaction)
59 .await
60 .map_err(infrastructure)?;
61 if existing == record {
62 Ok(())
63 } else {
64 Err(AuditLedgerError::EventConflict(entry.record.event_id))
65 }
66 }
67
68 async fn transition(
69 &self,
70 event_ids: &[Uuid],
71 worker_id: &str,
72 transition: JournalTransition<'_>,
73 ) -> Result<(), AuditLedgerError> {
74 validate_transition(event_ids, worker_id)?;
75 let mut transaction = self
76 .pool
77 .begin_with("BEGIN IMMEDIATE")
78 .await
79 .map_err(infrastructure)?;
80 let mut count = QueryBuilder::<Sqlite>::new(
81 "SELECT COUNT(*) FROM minco_audit_journal WHERE status = 'claimed' AND claimed_by = ",
82 );
83 count.push_bind(worker_id).push(" AND event_id IN (");
84 push_uuid_list(&mut count, event_ids);
85 count.push(")");
86 let claimed: i64 = count
87 .build_query_scalar()
88 .fetch_one(&mut *transaction)
89 .await
90 .map_err(infrastructure)?;
91 if usize::try_from(claimed).ok() != Some(event_ids.len()) {
92 return Err(AuditLedgerError::JournalClaimLost);
93 }
94
95 let mut statement = match transition {
96 JournalTransition::Delivered => {
97 QueryBuilder::<Sqlite>::new("DELETE FROM minco_audit_journal WHERE event_id IN (")
98 }
99 JournalTransition::Retry { .. } | JournalTransition::Quarantine { .. } => {
100 let mut builder = QueryBuilder::<Sqlite>::new("UPDATE minco_audit_journal SET ");
101 match transition {
102 JournalTransition::Retry {
103 failure_code,
104 retry_at,
105 } => {
106 validate_failure_code(failure_code)?;
107 builder
108 .push("status = 'failed', available_at = ")
109 .push_bind(retry_at)
110 .push(", failure_code = ")
111 .push_bind(failure_code);
112 }
113 JournalTransition::Quarantine { failure_code } => {
114 validate_failure_code(failure_code)?;
115 builder
116 .push("status = 'quarantined', failure_code = ")
117 .push_bind(failure_code);
118 }
119 JournalTransition::Delivered => unreachable!(),
120 }
121 builder.push(", claimed_by = NULL, claim_expires_at = NULL WHERE event_id IN (");
122 builder
123 }
124 };
125 push_uuid_list(&mut statement, event_ids);
126 statement.push(")");
127 let affected = statement
128 .build()
129 .execute(&mut *transaction)
130 .await
131 .map_err(infrastructure)?
132 .rows_affected();
133 if usize::try_from(affected).ok() != Some(event_ids.len()) {
134 return Err(AuditLedgerError::JournalClaimLost);
135 }
136 transaction.commit().await.map_err(infrastructure)
137 }
138}
139
140enum JournalTransition<'a> {
141 Delivered,
142 Retry {
143 failure_code: &'a str,
144 retry_at: DateTime<Utc>,
145 },
146 Quarantine {
147 failure_code: &'a str,
148 },
149}
150
151#[async_trait]
152impl AuditJournalStore for SqliteAuditJournal {
153 async fn enqueue(&self, entry: AuditJournalEntry) -> Result<(), AuditLedgerError> {
154 let mut transaction = self
155 .pool
156 .begin_with("BEGIN IMMEDIATE")
157 .await
158 .map_err(infrastructure)?;
159 self.enqueue_in(&mut transaction, entry).await?;
160 transaction.commit().await.map_err(infrastructure)
161 }
162
163 async fn claim_pending(
164 &self,
165 worker_id: &str,
166 limit: usize,
167 claim_expires_at: DateTime<Utc>,
168 ) -> Result<Vec<AuditJournalEntry>, AuditLedgerError> {
169 validate_claim(worker_id, limit, claim_expires_at)?;
170 let limit = i64::try_from(limit).map_err(|_| AuditLedgerError::InvalidJournalClaim)?;
171 let now = Utc::now();
172 let mut transaction = self
173 .pool
174 .begin_with("BEGIN IMMEDIATE")
175 .await
176 .map_err(infrastructure)?;
177 let ids = sqlx::query_scalar::<_, Uuid>(
178 "SELECT event_id FROM minco_audit_journal
179 WHERE status IN ('pending', 'failed') AND available_at <= ?
180 ORDER BY available_at, occurred_at, event_id LIMIT ?",
181 )
182 .bind(now)
183 .bind(limit)
184 .fetch_all(&mut *transaction)
185 .await
186 .map_err(infrastructure)?;
187 if ids.is_empty() {
188 transaction.commit().await.map_err(infrastructure)?;
189 return Ok(Vec::new());
190 }
191 let mut update = QueryBuilder::<Sqlite>::new(
192 "UPDATE minco_audit_journal SET status = 'claimed', claimed_by = ",
193 );
194 update
195 .push_bind(worker_id)
196 .push(", claim_expires_at = ")
197 .push_bind(claim_expires_at)
198 .push(", attempt_count = attempt_count + 1 WHERE event_id IN (");
199 push_uuid_list(&mut update, &ids);
200 update.push(")");
201 update
202 .build()
203 .execute(&mut *transaction)
204 .await
205 .map_err(infrastructure)?;
206 let entries = fetch_entries(&mut transaction, &ids).await?;
207 transaction.commit().await.map_err(infrastructure)?;
208 Ok(entries)
209 }
210
211 async fn mark_delivered(
212 &self,
213 event_ids: &[Uuid],
214 worker_id: &str,
215 ) -> Result<(), AuditLedgerError> {
216 self.transition(event_ids, worker_id, JournalTransition::Delivered)
217 .await
218 }
219
220 async fn mark_retry(
221 &self,
222 event_ids: &[Uuid],
223 worker_id: &str,
224 failure_code: &str,
225 retry_at: DateTime<Utc>,
226 ) -> Result<(), AuditLedgerError> {
227 self.transition(
228 event_ids,
229 worker_id,
230 JournalTransition::Retry {
231 failure_code,
232 retry_at,
233 },
234 )
235 .await
236 }
237
238 async fn quarantine(
239 &self,
240 event_ids: &[Uuid],
241 worker_id: &str,
242 failure_code: &str,
243 ) -> Result<(), AuditLedgerError> {
244 self.transition(
245 event_ids,
246 worker_id,
247 JournalTransition::Quarantine { failure_code },
248 )
249 .await
250 }
251
252 async fn recover_expired_claims(&self, now: DateTime<Utc>) -> Result<usize, AuditLedgerError> {
253 let result = sqlx::query(
254 "UPDATE minco_audit_journal
255 SET status = 'failed', available_at = ?, claimed_by = NULL,
256 claim_expires_at = NULL, failure_code = 'AUDIT-CLAIM-EXPIRED'
257 WHERE status = 'claimed' AND claim_expires_at <= ?",
258 )
259 .bind(now)
260 .bind(now)
261 .execute(&self.pool)
262 .await
263 .map_err(infrastructure)?;
264 usize::try_from(result.rows_affected()).map_err(|_| AuditLedgerError::Infrastructure)
265 }
266}
267
268async fn fetch_entries(
269 transaction: &mut Transaction<'_, Sqlite>,
270 ids: &[Uuid],
271) -> Result<Vec<AuditJournalEntry>, AuditLedgerError> {
272 let mut query =
273 QueryBuilder::<Sqlite>::new("SELECT * FROM minco_audit_journal WHERE event_id IN (");
274 push_uuid_list(&mut query, ids);
275 query.push(") ORDER BY available_at, occurred_at, event_id");
276 query
277 .build()
278 .fetch_all(&mut **transaction)
279 .await
280 .map_err(infrastructure)?
281 .iter()
282 .map(decode_journal_entry)
283 .collect()
284}
285
286fn decode_journal_entry(
287 row: &sqlx::sqlite::SqliteRow,
288) -> Result<AuditJournalEntry, AuditLedgerError> {
289 let record: String = row.try_get("record").map_err(infrastructure)?;
290 let status: String = row.try_get("status").map_err(infrastructure)?;
291 let record: AuditRecordV2 =
292 serde_json::from_str(&record).map_err(|_| AuditLedgerError::Encoding)?;
293 let encoded_bytes: i64 = row.try_get("encoded_bytes").map_err(infrastructure)?;
294 let attempt_count: i64 = row.try_get("attempt_count").map_err(infrastructure)?;
295 Ok(AuditJournalEntry {
296 record,
297 status: decode_status(&status)?,
298 attempt_count: u32::try_from(attempt_count)
299 .map_err(|_| AuditLedgerError::Infrastructure)?,
300 encoded_bytes: usize::try_from(encoded_bytes)
301 .map_err(|_| AuditLedgerError::Infrastructure)?,
302 available_at: row.try_get("available_at").map_err(infrastructure)?,
303 claimed_by: row.try_get("claimed_by").map_err(infrastructure)?,
304 claim_expires_at: row.try_get("claim_expires_at").map_err(infrastructure)?,
305 failure_code: row.try_get("failure_code").map_err(infrastructure)?,
306 })
307}
308
309fn decode_status(value: &str) -> Result<AuditJournalStatus, AuditLedgerError> {
310 match value {
311 "pending" => Ok(AuditJournalStatus::Pending),
312 "claimed" => Ok(AuditJournalStatus::Claimed),
313 "failed" => Ok(AuditJournalStatus::Failed),
314 "quarantined" => Ok(AuditJournalStatus::Quarantined),
315 _ => Err(AuditLedgerError::Infrastructure),
316 }
317}
318
319#[derive(Debug, Clone)]
320pub struct SqliteAuditLedger {
321 pool: SqlitePool,
322}
323
324impl SqliteAuditLedger {
325 pub const fn new(pool: SqlitePool) -> Self {
326 Self { pool }
327 }
328}
329
330#[async_trait]
331impl AuditLedgerWriter for SqliteAuditLedger {
332 async fn append_batch(
333 &self,
334 records: &[AuditRecordV2],
335 ) -> Result<AuditAppendReport, AuditLedgerError> {
336 let prepared = prepare_batch(records)?;
337 let mut transaction = self
338 .pool
339 .begin_with("BEGIN IMMEDIATE")
340 .await
341 .map_err(infrastructure)?;
342 let existing = fetch_existing_records(&mut transaction, prepared.keys().copied()).await?;
343 let mut new = Vec::new();
344 let mut duplicates = records.len().saturating_sub(prepared.len());
345 for (event_id, item) in &prepared {
346 match existing.get(event_id) {
347 Some(value) if value == &item.1 => duplicates += 1,
348 Some(_) => return Err(AuditLedgerError::EventConflict(*event_id)),
349 None => new.push(item),
350 }
351 }
352 if !new.is_empty() {
353 let mut insert = QueryBuilder::<Sqlite>::new(
354 "INSERT INTO minco_audit_records
355 (event_id, tenant_scope, resource_type, resource_id, occurred_at,
356 recorded_at, encoded_bytes, record) ",
357 );
358 insert.push_values(&new, |mut row, item| {
359 row.push_bind(item.0.event_id)
360 .push_bind(&item.0.tenant_scope)
361 .push_bind(&item.0.resource.resource_type)
362 .push_bind(&item.0.resource.resource_id)
363 .push_bind(item.0.occurred_at)
364 .push_bind(item.0.recorded_at)
365 .push_bind(i64::try_from(item.2).expect("validated audit record size"))
366 .push_bind(&item.1);
367 });
368 insert
369 .build()
370 .execute(&mut *transaction)
371 .await
372 .map_err(infrastructure)?;
373 let related = new
374 .iter()
375 .flat_map(|item| {
376 let record = &item.0;
377 record
378 .related_resources
379 .iter()
380 .map(move |related| (record, related))
381 })
382 .collect::<Vec<_>>();
383 if !related.is_empty() {
384 let mut insert_related = QueryBuilder::<Sqlite>::new(
385 "INSERT INTO minco_audit_related_resources
386 (event_id, tenant_scope, relation, resource_type, resource_id, occurred_at) ",
387 );
388 insert_related.push_values(related, |mut row, (record, related)| {
389 row.push_bind(record.event_id)
390 .push_bind(&record.tenant_scope)
391 .push_bind(&related.relation)
392 .push_bind(&related.resource.resource_type)
393 .push_bind(&related.resource.resource_id)
394 .push_bind(record.occurred_at);
395 });
396 insert_related
397 .build()
398 .execute(&mut *transaction)
399 .await
400 .map_err(infrastructure)?;
401 }
402 }
403 transaction.commit().await.map_err(infrastructure)?;
404 Ok(AuditAppendReport {
405 requested: records.len(),
406 inserted: new.len(),
407 duplicates,
408 })
409 }
410}
411
412type PreparedBatch = BTreeMap<Uuid, (AuditRecordV2, String, usize)>;
413
414fn prepare_batch(records: &[AuditRecordV2]) -> Result<PreparedBatch, AuditLedgerError> {
415 if records.is_empty() || records.len() > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS {
416 return Err(AuditLedgerError::InvalidBatch(
417 "invalid record count".into(),
418 ));
419 }
420 let mut prepared = BTreeMap::new();
421 let mut bytes = 0usize;
422 for record in records {
423 let encoded_bytes = record.validate()?;
424 bytes = bytes
425 .checked_add(encoded_bytes)
426 .ok_or_else(|| AuditLedgerError::InvalidBatch("batch bytes overflow".into()))?;
427 if bytes > minco_plugin_audit::MAX_AUDIT_BATCH_BYTES {
428 return Err(AuditLedgerError::BatchTooLarge {
429 bytes,
430 maximum: minco_plugin_audit::MAX_AUDIT_BATCH_BYTES,
431 });
432 }
433 let json = serde_json::to_string(record).map_err(|_| AuditLedgerError::Encoding)?;
434 if let Some(existing) =
435 prepared.insert(record.event_id, (record.clone(), json, encoded_bytes))
436 && existing.0 != *record
437 {
438 return Err(AuditLedgerError::EventConflict(record.event_id));
439 }
440 }
441 Ok(prepared)
442}
443
444async fn fetch_existing_records(
445 transaction: &mut Transaction<'_, Sqlite>,
446 ids: impl Iterator<Item = Uuid>,
447) -> Result<BTreeMap<Uuid, String>, AuditLedgerError> {
448 let ids = ids.collect::<Vec<_>>();
449 if ids.is_empty() {
450 return Ok(BTreeMap::new());
451 }
452 let mut query = QueryBuilder::<Sqlite>::new(
453 "SELECT event_id, record FROM minco_audit_records WHERE event_id IN (",
454 );
455 push_uuid_list(&mut query, &ids);
456 query.push(")");
457 query
458 .build()
459 .fetch_all(&mut **transaction)
460 .await
461 .map_err(infrastructure)?
462 .iter()
463 .map(|row| {
464 Ok((
465 row.try_get("event_id").map_err(infrastructure)?,
466 row.try_get("record").map_err(infrastructure)?,
467 ))
468 })
469 .collect()
470}
471
472#[async_trait]
473impl AuditReader for SqliteAuditLedger {
474 async fn list_resource_history(
475 &self,
476 query: &AuditQuery,
477 ) -> Result<AuditPage, AuditLedgerError> {
478 query.validate()?;
479 let mut statement = QueryBuilder::<Sqlite>::new(
480 "SELECT record, occurred_at, event_id FROM minco_audit_records AS audit WHERE tenant_scope = ",
481 );
482 statement
483 .push_bind(&query.tenant_scope)
484 .push(" AND ((resource_type = ")
485 .push_bind(&query.resource.resource_type)
486 .push(" AND resource_id = ")
487 .push_bind(&query.resource.resource_id)
488 .push(")");
489 if query.include_related {
490 statement.push(
491 " OR EXISTS (SELECT 1 FROM minco_audit_related_resources AS related
492 WHERE related.event_id = audit.event_id AND related.tenant_scope = ",
493 );
494 statement
495 .push_bind(&query.tenant_scope)
496 .push(" AND related.resource_type = ")
497 .push_bind(&query.resource.resource_type)
498 .push(" AND related.resource_id = ")
499 .push_bind(&query.resource.resource_id);
500 if let Some(relation) = &query.relation {
501 statement
502 .push(" AND related.relation = ")
503 .push_bind(relation);
504 }
505 statement.push(")");
506 }
507 statement.push(")");
508 if let Some(after) = query.after {
509 let comparator = match query.direction {
510 minco_plugin_audit::AuditSortDirection::OldestFirst => ">",
511 minco_plugin_audit::AuditSortDirection::NewestFirst => "<",
512 };
513 statement
514 .push(" AND (occurred_at, event_id) ")
515 .push(comparator)
516 .push(" (")
517 .push_bind(after.occurred_at)
518 .push(", ")
519 .push_bind(after.event_id)
520 .push(")");
521 }
522 let direction = match query.direction {
523 minco_plugin_audit::AuditSortDirection::OldestFirst => "ASC",
524 minco_plugin_audit::AuditSortDirection::NewestFirst => "DESC",
525 };
526 statement
527 .push(" ORDER BY occurred_at ")
528 .push(direction)
529 .push(", event_id ")
530 .push(direction)
531 .push(" LIMIT ")
532 .push_bind(
533 i64::try_from(query.limit + 1)
534 .map_err(|_| AuditLedgerError::InvalidQuery("limit".into()))?,
535 );
536 let rows = statement
537 .build()
538 .fetch_all(&self.pool)
539 .await
540 .map_err(infrastructure)?;
541 decode_page(rows, query.limit)
542 }
543}
544
545fn decode_page(
546 rows: Vec<sqlx::sqlite::SqliteRow>,
547 limit: usize,
548) -> Result<AuditPage, AuditLedgerError> {
549 let has_more = rows.len() > limit;
550 let mut records = rows
551 .into_iter()
552 .take(limit)
553 .map(|row| {
554 let value: String = row.try_get("record").map_err(infrastructure)?;
555 let record: AuditRecordV2 =
556 serde_json::from_str(&value).map_err(|_| AuditLedgerError::Encoding)?;
557 record.validate()?;
558 Ok(record)
559 })
560 .collect::<Result<Vec<_>, AuditLedgerError>>()?;
561 let next_cursor = has_more.then(|| {
562 records
563 .last()
564 .map(AuditCursor::from)
565 .expect("positive validated query limit")
566 });
567 Ok(AuditPage {
568 records: std::mem::take(&mut records),
569 next_cursor,
570 })
571}
572
573#[derive(Debug, Clone)]
574pub struct SqliteAuditStorageInspector {
575 source: SqlitePool,
576 ledger: SqlitePool,
577 policy: AuditLifecyclePolicy,
578}
579
580impl SqliteAuditStorageInspector {
581 pub fn new(
582 source: SqlitePool,
583 ledger: SqlitePool,
584 policy: AuditLifecyclePolicy,
585 ) -> Result<Self, AuditLedgerError> {
586 policy.validate()?;
587 Ok(Self {
588 source,
589 ledger,
590 policy,
591 })
592 }
593}
594
595#[async_trait]
596impl AuditStorageInspector for SqliteAuditStorageInspector {
597 async fn storage_health(&self) -> Result<AuditStorageHealth, AuditLedgerError> {
598 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
599 .fetch_one(&self.ledger)
600 .await
601 .map_err(infrastructure)?;
602 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
603 .fetch_one(&self.ledger)
604 .await
605 .map_err(infrastructure)?;
606 let hot_bytes = u64::try_from(page_count.saturating_mul(page_size))
607 .map_err(|_| AuditLedgerError::Infrastructure)?;
608 let ledger_file = main_database_file(&self.ledger).await?;
609 let free_bytes = fs2::available_space(ledger_file).map_err(infrastructure)?;
610 let row = sqlx::query(
611 "SELECT COUNT(*) AS pending_records,
612 COALESCE(SUM(encoded_bytes), 0) AS pending_bytes,
613 MIN(occurred_at) AS oldest_pending
614 FROM minco_audit_journal WHERE status IN ('pending', 'failed', 'claimed')",
615 )
616 .fetch_one(&self.source)
617 .await
618 .map_err(infrastructure)?;
619 let pending_records: i64 = row.try_get("pending_records").map_err(infrastructure)?;
620 let pending_bytes: i64 = row.try_get("pending_bytes").map_err(infrastructure)?;
621 let oldest_pending: Option<DateTime<Utc>> =
622 row.try_get("oldest_pending").map_err(infrastructure)?;
623 let quarantined: i64 = sqlx::query_scalar(
624 "SELECT COUNT(*) FROM minco_audit_journal WHERE status = 'quarantined'",
625 )
626 .fetch_one(&self.source)
627 .await
628 .map_err(infrastructure)?;
629 let record_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM minco_audit_records")
630 .fetch_one(&self.ledger)
631 .await
632 .map_err(infrastructure)?;
633 let bounds = ledger_bounds(&self.ledger).await?;
634 let snapshot = AuditStorageSnapshot {
635 provider: "sqlite".into(),
636 hot_bytes,
637 free_bytes: Some(free_bytes),
638 pending_records: u64::try_from(pending_records)
639 .map_err(|_| AuditLedgerError::Infrastructure)?,
640 pending_bytes: u64::try_from(pending_bytes)
641 .map_err(|_| AuditLedgerError::Infrastructure)?,
642 oldest_pending_seconds: oldest_pending.map(|time| {
643 u64::try_from((Utc::now() - time).num_seconds().max(0)).unwrap_or(u64::MAX)
644 }),
645 quarantined_records: u64::try_from(quarantined)
646 .map_err(|_| AuditLedgerError::Infrastructure)?,
647 archive_watermark: None,
648 segments: vec![AuditSegmentStatus {
649 segment_id: 1,
650 state: AuditSegmentState::Active,
651 record_count: u64::try_from(record_count)
652 .map_err(|_| AuditLedgerError::Infrastructure)?,
653 encoded_bytes: hot_bytes,
654 first: bounds.0,
655 last: bounds.1,
656 archive_receipt: None,
657 }],
658 };
659 evaluate_storage_health(self.policy, snapshot)
660 }
661}
662
663async fn ledger_bounds(
664 pool: &SqlitePool,
665) -> Result<(Option<AuditCursor>, Option<AuditCursor>), AuditLedgerError> {
666 let first = sqlx::query(
667 "SELECT occurred_at, event_id FROM minco_audit_records ORDER BY occurred_at, event_id LIMIT 1",
668 )
669 .fetch_optional(pool)
670 .await
671 .map_err(infrastructure)?
672 .map(|row| decode_cursor(&row))
673 .transpose()?;
674 let last = sqlx::query(
675 "SELECT occurred_at, event_id FROM minco_audit_records ORDER BY occurred_at DESC, event_id DESC LIMIT 1",
676 )
677 .fetch_optional(pool)
678 .await
679 .map_err(infrastructure)?
680 .map(|row| decode_cursor(&row))
681 .transpose()?;
682 Ok((first, last))
683}
684
685fn decode_cursor(row: &sqlx::sqlite::SqliteRow) -> Result<AuditCursor, AuditLedgerError> {
686 Ok(AuditCursor {
687 occurred_at: row.try_get("occurred_at").map_err(infrastructure)?,
688 event_id: row.try_get("event_id").map_err(infrastructure)?,
689 })
690}
691
692pub async fn validate_separate_audit_pools(
693 source: &SqlitePool,
694 ledger: &SqlitePool,
695) -> Result<(), AuditLedgerError> {
696 let source_file = main_database_file(source).await?;
697 let ledger_file = main_database_file(ledger).await?;
698 if source_file.is_empty() || ledger_file.is_empty() || source_file == ledger_file {
699 return Err(AuditLedgerError::InvalidLifecycle(
700 "SQLite audit ledger requires a distinct file-backed database".into(),
701 ));
702 }
703 Ok(())
704}
705
706async fn main_database_file(pool: &SqlitePool) -> Result<String, AuditLedgerError> {
707 sqlx::query("PRAGMA database_list")
708 .fetch_all(pool)
709 .await
710 .map_err(infrastructure)?
711 .into_iter()
712 .find(|row| row.try_get::<String, _>("name").ok().as_deref() == Some("main"))
713 .ok_or(AuditLedgerError::Infrastructure)?
714 .try_get("file")
715 .map_err(infrastructure)
716}
717
718pub async fn migrate_audit_ledger(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> {
719 let mut migrator = sqlx::migrate!("migrations/audit-ledger");
720 migrator.dangerous_set_table_name("_minco_audit_ledger_migrations");
721 migrator.run(pool).await
722}
723
724fn validate_pending_entry(entry: &AuditJournalEntry) -> Result<(), AuditLedgerError> {
725 if entry.status != AuditJournalStatus::Pending
726 || entry.encoded_bytes != entry.record.validate()?
727 {
728 Err(AuditLedgerError::InvalidJournalEntry)
729 } else {
730 Ok(())
731 }
732}
733
734fn validate_claim(
735 worker_id: &str,
736 limit: usize,
737 claim_expires_at: DateTime<Utc>,
738) -> Result<(), AuditLedgerError> {
739 let now = Utc::now();
740 if worker_id.trim().is_empty()
741 || worker_id.len() > 128
742 || worker_id.chars().any(char::is_control)
743 || limit == 0
744 || limit > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS
745 || claim_expires_at <= now
746 || claim_expires_at > now + TimeDelta::hours(1)
747 {
748 Err(AuditLedgerError::InvalidJournalClaim)
749 } else {
750 Ok(())
751 }
752}
753
754fn validate_transition(event_ids: &[Uuid], worker_id: &str) -> Result<(), AuditLedgerError> {
755 if event_ids.is_empty()
756 || event_ids.len() > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS
757 || worker_id.trim().is_empty()
758 || worker_id.len() > 128
759 || worker_id.chars().any(char::is_control)
760 {
761 Err(AuditLedgerError::InvalidJournalClaim)
762 } else {
763 Ok(())
764 }
765}
766
767fn validate_failure_code(value: &str) -> Result<(), AuditLedgerError> {
768 if value.is_empty()
769 || value.len() > 128
770 || !value
771 .bytes()
772 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'-')
773 {
774 Err(AuditLedgerError::InvalidJournalEntry)
775 } else {
776 Ok(())
777 }
778}
779
780fn push_uuid_list(builder: &mut QueryBuilder<Sqlite>, ids: &[Uuid]) {
781 let mut separated = builder.separated(", ");
782 for id in ids {
783 separated.push_bind(*id);
784 }
785}
786
787fn infrastructure(_: impl std::fmt::Display) -> AuditLedgerError {
788 AuditLedgerError::Infrastructure
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use minco_plugin_audit::{
795 AuditActor, AuditRelatedResource, AuditRelay, AuditResourceRef, AuditSortDirection,
796 };
797 use std::{path::PathBuf, sync::Arc};
798
799 struct Databases {
800 _directory: tempfile::TempDir,
801 source: SqlitePool,
802 ledger: SqlitePool,
803 source_path: PathBuf,
804 ledger_path: PathBuf,
805 }
806
807 impl Databases {
808 async fn new() -> Self {
809 let directory = tempfile::tempdir().unwrap();
810 let source_path = directory.path().join("source.sqlite");
811 let ledger_path = directory.path().join("audit.sqlite");
812 let source = crate::connect(&crate::SqlitePoolConfig::file(&source_path))
813 .await
814 .unwrap();
815 let ledger = crate::connect(&crate::SqlitePoolConfig::file(&ledger_path))
816 .await
817 .unwrap();
818 crate::plugin_adapters::migrate_plugin_storage(&source)
819 .await
820 .unwrap();
821 migrate_audit_ledger(&ledger).await.unwrap();
822 validate_separate_audit_pools(&source, &ledger)
823 .await
824 .unwrap();
825 Self {
826 _directory: directory,
827 source,
828 ledger,
829 source_path,
830 ledger_path,
831 }
832 }
833 }
834
835 fn record(index: u32) -> AuditRecordV2 {
836 let mut record = AuditRecordV2::new(
837 "tenant",
838 "order.status_changed",
839 AuditResourceRef::new("order", "one"),
840 AuditActor::human("subject"),
841 "updateOrder",
842 Uuid::now_v7(),
843 );
844 record.event_id = Uuid::from_u128(10_000 + u128::from(index));
845 record.occurred_at = DateTime::from_timestamp(1_800_000_000 + i64::from(index), 0).unwrap();
846 record.recorded_at = record.occurred_at + TimeDelta::seconds(1);
847 record.resource_revision = Some(u64::from(index));
848 record
849 }
850
851 #[tokio::test]
852 async fn source_intent_rolls_back_with_domain_mutation() {
853 let databases = Databases::new().await;
854 sqlx::query("CREATE TABLE orders (id TEXT PRIMARY KEY)")
855 .execute(&databases.source)
856 .await
857 .unwrap();
858 let journal = SqliteAuditJournal::new(databases.source.clone());
859 let mut transaction = databases
860 .source
861 .begin_with("BEGIN IMMEDIATE")
862 .await
863 .unwrap();
864 sqlx::query("INSERT INTO orders (id) VALUES ('one')")
865 .execute(&mut *transaction)
866 .await
867 .unwrap();
868 journal
869 .enqueue_in(
870 &mut transaction,
871 AuditJournalEntry::pending(record(1)).unwrap(),
872 )
873 .await
874 .unwrap();
875 transaction.rollback().await.unwrap();
876 let orders: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM orders")
877 .fetch_one(&databases.source)
878 .await
879 .unwrap();
880 let intents: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM minco_audit_journal")
881 .fetch_one(&databases.source)
882 .await
883 .unwrap();
884 assert_eq!((orders, intents), (0, 0));
885 }
886
887 #[tokio::test]
888 async fn retry_after_ledger_commit_is_deduplicated_and_acknowledged() {
889 let databases = Databases::new().await;
890 let journal = Arc::new(SqliteAuditJournal::new(databases.source.clone()));
891 let ledger = Arc::new(SqliteAuditLedger::new(databases.ledger.clone()));
892 journal
893 .enqueue(AuditJournalEntry::pending(record(1)).unwrap())
894 .await
895 .unwrap();
896 let expires = Utc::now() + TimeDelta::milliseconds(1);
897 let claimed = journal
898 .claim_pending("crashed-worker", 10, expires)
899 .await
900 .unwrap();
901 ledger
902 .append_batch(&[claimed[0].record.clone()])
903 .await
904 .unwrap();
905 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
906 let report = AuditRelay::new(journal, ledger)
907 .dispatch_once("recovery-worker", 10, TimeDelta::minutes(1))
908 .await
909 .unwrap();
910 assert_eq!(report.duplicates, 1);
911 assert_eq!(report.inserted, 0);
912 let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM minco_audit_journal")
913 .fetch_one(&databases.source)
914 .await
915 .unwrap();
916 assert_eq!(pending, 0);
917 }
918
919 #[tokio::test]
920 async fn pages_and_related_history_are_stable_in_separate_ledger() {
921 let databases = Databases::new().await;
922 assert_ne!(databases.source_path, databases.ledger_path);
923 let ledger = SqliteAuditLedger::new(databases.ledger.clone());
924 let mut records = (1..=5).map(record).collect::<Vec<_>>();
925 records[2].resource = AuditResourceRef::new("shift", "shift-one");
926 records[2].related_resources.push(AuditRelatedResource {
927 relation: "order".into(),
928 resource: AuditResourceRef::new("order", "one"),
929 });
930 let report = ledger.append_batch(&records).await.unwrap();
931 assert_eq!(report.inserted, 5);
932
933 let mut query = AuditQuery::for_resource("tenant", AuditResourceRef::new("order", "one"));
934 query.direction = AuditSortDirection::OldestFirst;
935 query.include_related = true;
936 query.relation = Some("order".into());
937 query.limit = 2;
938 let first = ledger.list_resource_history(&query).await.unwrap();
939 assert_eq!(first.records.len(), 2);
940 query.after = first.next_cursor;
941 let second = ledger.list_resource_history(&query).await.unwrap();
942 assert_eq!(second.records.len(), 2);
943 query.after = second.next_cursor;
944 let third = ledger.list_resource_history(&query).await.unwrap();
945 assert_eq!(third.records.len(), 1);
946 let revisions = first
947 .records
948 .into_iter()
949 .chain(second.records)
950 .chain(third.records)
951 .map(|record| record.resource_revision.unwrap())
952 .collect::<Vec<_>>();
953 assert_eq!(revisions, vec![1, 2, 3, 4, 5]);
954 }
955
956 #[tokio::test]
957 async fn same_pool_is_rejected_and_health_reports_real_ledger_bytes() {
958 let databases = Databases::new().await;
959 assert!(
960 validate_separate_audit_pools(&databases.source, &databases.source)
961 .await
962 .is_err()
963 );
964 let inspector = SqliteAuditStorageInspector::new(
965 databases.source,
966 databases.ledger,
967 AuditLifecyclePolicy::sqlite_100_mib(64 * 1024 * 1024),
968 )
969 .unwrap();
970 let health = inspector.storage_health().await.unwrap();
971 assert_eq!(health.snapshot.provider, "sqlite");
972 assert!(health.snapshot.hot_bytes > 0);
973 assert!(health.snapshot.free_bytes.is_some_and(|bytes| bytes > 0));
974 }
975}