Skip to main content

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