Skip to main content

minco_sqlx_sqlite/
audit_v2.rs

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}