#![cfg(feature = "sqlite")]
#![allow(clippy::must_use_candidate, clippy::missing_const_for_fn)]
#![allow(clippy::float_cmp)]
use autumn_web::config::DatabaseConfig;
use autumn_web::current::with_actor;
use autumn_web::db::{RuntimeConnection, create_pool};
use autumn_web::hooks::Patch;
use autumn_web::ledger::{LedgerAsOf, LedgerBreak, LedgerPageRequest};
use autumn_web::reexports::{chrono, diesel, diesel_async};
use autumn_web::tenancy::with_tenant;
use autumn_web::version_history::VersionOp;
use chrono::{DateTime, Duration, Utc};
use diesel_async::pooled_connection::deadpool::Pool;
use diesel_async::{RunQueryDsl as _, SimpleAsyncConnection as _};
type SqlitePool = Pool<RuntimeConnection>;
mod schema {
autumn_web::reexports::diesel::table! {
lg_invoices (id) {
id -> Int8,
reference -> Text,
amount_cents -> Int8,
amount_rate -> Double,
metadata -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_effective_notes (id) {
id -> Int8,
body -> Text,
effective_at -> Timestamp,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_renamed_notes (id) {
id -> Int8,
title -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_sealed_notes (id) {
id -> Int8,
body -> Text,
secret -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_vault_notes (id) {
id -> Int8,
body -> Text,
secret -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_cascade_parents (id) {
id -> Int8,
name -> Text,
}
}
autumn_web::reexports::diesel::table! {
lg_cascade_children (id) {
id -> Int8,
parent_id -> Int8,
label -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_secret_notes (id) {
id -> Int8,
body -> Text,
internal_note -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_tenant_invoices (id) {
id -> Int8,
reference -> Text,
tenant_id -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
autumn_web::reexports::diesel::table! {
lg_movable_invoices (id) {
id -> Int8,
reference -> Text,
tenant_id -> Text,
deleted_at -> Nullable<Timestamp>,
}
}
}
use schema::{
lg_cascade_children, lg_cascade_parents, lg_effective_notes, lg_invoices, lg_movable_invoices,
lg_renamed_notes, lg_sealed_notes, lg_secret_notes, lg_tenant_invoices, lg_vault_notes,
};
#[autumn_web::model(table = "lg_invoices")]
pub struct LgInvoice {
#[id]
pub id: i64,
pub reference: String,
pub amount_cents: i64,
pub amount_rate: f64,
pub metadata: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(LgInvoice, table = "lg_invoices", soft_delete, ledgered = true)]
pub trait LgInvoiceRepository {}
#[autumn_web::model(table = "lg_tenant_invoices")]
#[derive(PartialEq, Eq)]
pub struct LgTenantInvoice {
#[id]
pub id: i64,
pub reference: String,
#[default]
pub tenant_id: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(
LgTenantInvoice,
table = "lg_tenant_invoices",
tenant_scoped,
soft_delete,
ledgered = true
)]
pub trait LgTenantInvoiceRepository {}
#[autumn_web::model(table = "lg_movable_invoices")]
pub struct LgMovableInvoice {
#[id]
pub id: i64,
pub reference: String,
pub tenant_id: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(
LgMovableInvoice,
table = "lg_movable_invoices",
tenant_scoped,
soft_delete,
ledgered = true
)]
pub trait LgMovableInvoiceRepository {}
#[autumn_web::model(table = "lg_effective_notes")]
#[derive(PartialEq, Eq)]
pub struct LgEffectiveNote {
#[id]
pub id: i64,
pub body: String,
pub effective_at: chrono::NaiveDateTime,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(
LgEffectiveNote,
table = "lg_effective_notes",
soft_delete,
ledgered(valid_time = "effective_at")
)]
pub trait LgEffectiveNoteRepository {}
#[autumn_web::model(table = "lg_renamed_notes")]
#[derive(PartialEq, Eq)]
pub struct LgRenamedNote {
#[id]
pub id: i64,
pub title: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
fn upcast_lg_renamed_note(from: u32, snapshot: &mut serde_json::Value) -> Result<(), String> {
if from < 2 {
let object = snapshot
.as_object_mut()
.ok_or("the snapshot is not an object")?;
if object.contains_key("legacy_flag") {
return Err("a legacy shape with no rule".to_string());
}
let subject = object
.remove("subject")
.ok_or("the snapshot has no `subject`")?;
object.insert("title".to_string(), subject);
}
Ok(())
}
#[autumn_web::repository(
LgRenamedNote,
table = "lg_renamed_notes",
soft_delete,
ledgered(schema_version = 2, upcast = upcast_lg_renamed_note)
)]
pub trait LgRenamedNoteRepository {}
#[autumn_web::model(table = "lg_sealed_notes")]
pub struct LgSealedNote {
#[id]
pub id: i64,
pub body: String,
#[encrypted]
pub secret: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
fn upcast_lg_sealed_note(from: u32, snapshot: &mut serde_json::Value) -> Result<(), String> {
if from < 2 {
let object = snapshot
.as_object_mut()
.ok_or("the snapshot is not an object")?;
let hidden = object
.remove("hidden")
.ok_or("the snapshot has no `hidden`")?;
object.insert("secret".to_string(), hidden);
}
Ok(())
}
#[autumn_web::repository(
LgSealedNote,
table = "lg_sealed_notes",
soft_delete,
ledgered(schema_version = 2, upcast = upcast_lg_sealed_note)
)]
pub trait LgSealedNoteRepository {}
#[autumn_web::model(table = "lg_cascade_children")]
pub struct LgCascadeChild {
#[id]
pub id: i64,
pub parent_id: i64,
pub label: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(
LgCascadeChild,
table = "lg_cascade_children",
soft_delete,
ledgered = true
)]
pub trait LgCascadeChildRepository {}
#[autumn_web::model(table = "lg_cascade_parents")]
pub struct LgCascadeParent {
#[id]
pub id: i64,
pub name: String,
}
#[autumn_web::repository(
LgCascadeParent,
table = "lg_cascade_parents",
dependent(PgLgCascadeChildRepository, fk = "parent_id", on_delete = destroy)
)]
pub trait LgCascadeParentRepository {}
#[autumn_web::model(table = "lg_vault_notes")]
pub struct LgVaultNote {
#[id]
pub id: i64,
pub body: String,
#[encrypted]
pub secret: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(LgVaultNote, table = "lg_vault_notes", soft_delete, ledgered = true)]
pub trait LgVaultNoteRepository {}
#[autumn_web::model(table = "lg_secret_notes")]
#[derive(PartialEq, Eq)]
pub struct LgSecretNote {
#[id]
pub id: i64,
pub body: String,
#[private]
pub internal_note: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(LgSecretNote, table = "lg_secret_notes", soft_delete, ledgered = true)]
pub trait LgSecretNoteRepository {}
const LEDGER_UP: &str = include_str!(
"../version_history_migrations_sqlite/20260826000000_create_ledger_revisions/up.sql"
);
const LEDGER_HIGH_WATER_UP: &str = include_str!(
"../version_history_migrations_sqlite/20260901213107_create_ledger_high_water/up.sql"
);
const VERSION_HISTORY_UP: &str = include_str!(
"../version_history_migrations_sqlite/20260526000000_create_version_history/up.sql"
);
fn install_encryption_keys() {
use std::sync::OnceLock;
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
const PRIMARY: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const DETERMINISTIC: &str =
"3333333333333333333333333333333333333333333333333333333333333333";
let ring = autumn_web::encryption::KeyRing::from_master_hex(
PRIMARY,
&[],
Some(DETERMINISTIC),
b"ledger-suite-salt",
)
.expect("fixture key material derives");
autumn_web::encryption::install_key_ring(ring);
});
}
async fn boot_pool(db_name: &str) -> SqlitePool {
let pool = boot_pool_without_high_water(db_name).await;
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
conn.batch_execute(LEDGER_HIGH_WATER_UP)
.await
.expect("apply the high-water migration");
}
pool
}
async fn boot_pool_without_high_water(db_name: &str) -> SqlitePool {
install_encryption_keys();
let config = DatabaseConfig {
url: Some(format!("sqlite://file:{db_name}?mode=memory&cache=shared")),
primary_pool_size: Some(1),
..Default::default()
};
let pool: SqlitePool = create_pool(&config)
.expect("sqlite pool builds via build_sqlite_pool")
.expect("a url is configured");
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
for ddl in [
"CREATE TABLE lg_invoices (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
reference TEXT NOT NULL, \
amount_cents BIGINT NOT NULL, \
amount_rate DOUBLE PRECISION NOT NULL, \
metadata TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_tenant_invoices (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
reference TEXT NOT NULL, \
tenant_id TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_movable_invoices (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
reference TEXT NOT NULL, \
tenant_id TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_vault_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
body TEXT NOT NULL, \
secret TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_cascade_parents (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
name TEXT NOT NULL\
)",
"CREATE TABLE lg_cascade_children (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
parent_id BIGINT NOT NULL REFERENCES lg_cascade_parents(id), \
label TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_secret_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
body TEXT NOT NULL, \
internal_note TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_renamed_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
title TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_sealed_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
body TEXT NOT NULL, \
secret TEXT NOT NULL, \
deleted_at TIMESTAMP\
)",
"CREATE TABLE lg_effective_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
body TEXT NOT NULL, \
effective_at TIMESTAMP NOT NULL, \
deleted_at TIMESTAMP\
)",
VERSION_HISTORY_UP,
LEDGER_UP,
] {
conn.batch_execute(ddl)
.await
.unwrap_or_else(|err| panic!("apply DDL: {err}\n{ddl}"));
}
}
pool
}
fn assert_same_record<T: serde::Serialize>(left: &T, right: &T, context: &str) {
assert_eq!(
serde_json::to_string(left).expect("serialize left"),
serde_json::to_string(right).expect("serialize right"),
"{context}"
);
}
async fn resync_high_water_mark(pool: &SqlitePool, table: &str, record_id: i64) {
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_high_water WHERE table_name = ? AND record_id = ?",
)
.bind::<diesel::sql_types::Text, _>(table)
.bind::<diesel::sql_types::BigInt, _>(record_id)
.execute(&mut *conn)
.await
.expect("clear the mark");
diesel::sql_query(
"INSERT INTO _autumn_ledger_high_water \
(table_name, tenant_key, record_id, high_seq, head_hash, recorded_at) \
SELECT table_name, COALESCE(tenant_id, ''), record_id, seq, hash, recorded_at \
FROM _autumn_ledger_revisions \
WHERE table_name = ? AND record_id = ? ORDER BY seq DESC LIMIT 1",
)
.bind::<diesel::sql_types::Text, _>(table)
.bind::<diesel::sql_types::BigInt, _>(record_id)
.execute(&mut *conn)
.await
.expect("re-establish the mark over the surviving chain");
}
#[allow(
clippy::disallowed_methods,
reason = "test asserts against real recorded_at values"
)]
fn now() -> DateTime<Utc> {
Utc::now()
}
async fn tick() {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn golden_as_of_reconstruction_matches_the_oracle_and_tampering_is_detected() {
let pool = boot_pool("lg_golden").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let mut oracle: Vec<(DateTime<Utc>, LgInvoice)> = Vec::new();
let created = with_actor("alice", async {
repo.save(&NewLgInvoice {
reference: "INV-1".to_string(),
amount_cents: 1000,
amount_rate: 1e16,
metadata: r#"{"b":1,"a":{"y":2,"x":3}}"#.to_string(),
})
.await
.expect("insert")
})
.await;
let id = created.id;
tick().await;
oracle.push((now(), created.clone()));
tick().await;
let updated_once = with_actor("alice", async {
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(2000),
..Default::default()
},
)
.await
.expect("first update")
})
.await;
tick().await;
oracle.push((now(), updated_once.clone()));
tick().await;
let updated_twice = with_actor("bob", async {
repo.update(
id,
&UpdateLgInvoice {
reference: Patch::Set("INV-1-REV".to_string()),
amount_cents: Patch::Set(3000),
..Default::default()
},
)
.await
.expect("second update")
})
.await;
tick().await;
oracle.push((now(), updated_twice.clone()));
for (instant, expected) in &oracle {
let reconstructed = repo
.ledger_as_of(id, *instant)
.await
.expect("as-of read")
.expect("the record existed at this instant");
assert_same_record(
&reconstructed,
expected,
&format!("as-of state at {instant} must equal what a live query returned then"),
);
}
let before_creation = oracle[0].0 - Duration::seconds(60);
assert!(
repo.ledger_as_of(id, before_creation)
.await
.expect("as-of read")
.is_none(),
"as-of before creation must report that the record did not exist"
);
let report = repo.ledger_verify(id).await.expect("verify");
assert!(
report.is_intact(),
"an untouched chain must verify: {report:?}"
);
assert_eq!(report.revisions_checked, 3, "insert + two updates");
let head = repo
.ledger_head(id)
.await
.expect("head read")
.expect("a written record has a head");
assert_eq!(head.seq, 3);
assert_eq!(report.head_hash.as_deref(), Some(head.hash.as_str()));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions \
SET snapshot = json_set(snapshot, '$.amount_cents', 999999) \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("tamper with revision 2");
}
let tampered = repo
.ledger_verify(id)
.await
.expect("verify after tampering");
let broken = tampered
.broken
.expect("an out-of-band mutation must be detected");
assert_eq!(broken.kind, LedgerBreak::HashMismatch);
assert_eq!(broken.seq, 2, "verify must report the first broken link");
assert!(
tampered.head_hash.is_none(),
"a broken chain exports no head"
);
}
#[tokio::test]
async fn every_write_appends_a_revision_with_both_time_axes() {
let pool = boot_pool("lg_writes").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let id = with_actor("alice", async {
let created = repo
.save(&NewLgInvoice {
reference: "INV-2".to_string(),
amount_cents: 500,
amount_rate: 1e16,
metadata: r#"{"b":1,"a":{"y":2,"x":3}}"#.to_string(),
})
.await
.expect("insert");
repo.update(
created.id,
&UpdateLgInvoice {
amount_cents: Patch::Set(750),
..Default::default()
},
)
.await
.expect("update");
repo.delete_by_id(created.id).await.expect("soft delete");
created.id
})
.await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.op).collect::<Vec<_>>(),
vec![VersionOp::Insert, VersionOp::Update, VersionOp::Delete],
"insert, update and soft-delete each append exactly one revision"
);
assert_eq!(
revisions.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![1, 2, 3],
"sequence numbers are contiguous from 1"
);
for revision in &revisions {
assert_eq!(revision.actor, "alice", "the ambient actor is attributed");
assert_eq!(revision.table_name, "lg_invoices");
assert_eq!(revision.record_id, id);
assert!(
revision.snapshot.get("reference").is_some(),
"a revision carries a FULL snapshot, not just the changed columns"
);
assert!(
revision.snapshot.get("amount_cents").is_some(),
"a revision carries a FULL snapshot, not just the changed columns"
);
assert_eq!(
revision.valid_from, revision.recorded_at,
"valid time defaults to transaction time"
);
}
assert_eq!(
revisions[0].prev_hash, None,
"the chain head opens with no parent"
);
assert_eq!(
revisions[1].prev_hash.as_deref(),
Some(revisions[0].hash.as_str())
);
assert_eq!(
revisions[2].prev_hash.as_deref(),
Some(revisions[1].hash.as_str())
);
let after_delete = repo
.ledger_as_of_at(id, LedgerAsOf::default())
.await
.expect("as-of read")
.expect("a soft-deleted record still has state");
assert!(
after_delete.deleted_at.is_some(),
"the delete revision snapshots the soft-deleted row"
);
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn diff_reports_the_field_level_delta_between_two_instants() {
let pool = boot_pool("lg_diff").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let created = repo
.save(&NewLgInvoice {
reference: "INV-3".to_string(),
amount_cents: 100,
amount_rate: 1e16,
metadata: r#"{"b":1,"a":{"y":2,"x":3}}"#.to_string(),
})
.await
.expect("insert");
let id = created.id;
tick().await;
let after_create = now();
tick().await;
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(250),
..Default::default()
},
)
.await
.expect("update");
tick().await;
let after_update = now();
let diff = repo
.ledger_diff(id, after_create, after_update)
.await
.expect("diff");
assert_eq!(diff.from_seq, Some(1));
assert_eq!(diff.to_seq, Some(2));
assert_eq!(
diff.changes.len(),
1,
"only amount_cents changed: {:?}",
diff.changes
);
let change = &diff.changes[0];
assert_eq!(change.column, "amount_cents");
assert_eq!(
change.before.as_ref().and_then(serde_json::Value::as_i64),
Some(100)
);
assert_eq!(
change.after.as_ref().and_then(serde_json::Value::as_i64),
Some(250)
);
let quiet = repo
.ledger_diff(id, after_update, after_update + Duration::seconds(5))
.await
.expect("diff");
assert!(quiet.is_empty(), "{quiet:?}");
}
#[tokio::test]
async fn verify_detects_deletion_of_a_stored_revision() {
let pool = boot_pool("lg_tamper_delete").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("delete revision 2");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a deleted revision must be detected");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 2);
}
#[tokio::test]
async fn verify_detects_insertion_of_a_forged_revision() {
let pool = boot_pool("lg_tamper_insert").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"INSERT INTO _autumn_ledger_revisions \
(table_name, tenant_id, record_id, seq, op, actor, request_id, snapshot, \
valid_from, recorded_at, prev_hash, hash) \
SELECT table_name, tenant_id, record_id, 4, 'update', 'mallory', NULL, \
json_set(snapshot, '$.amount_cents', 1), valid_from, recorded_at, \
hash, 'f0f0f0f0' \
FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("insert a forged revision");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("an inserted revision must be detected");
assert_eq!(broken.kind, LedgerBreak::HashMismatch);
assert_eq!(broken.seq, 4);
}
#[tokio::test]
async fn verify_has_no_false_positives_on_an_untouched_chain() {
let pool = boot_pool("lg_no_false_positives").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let mut ids = Vec::new();
for n in 0..5 {
let created = repo
.save(&NewLgInvoice {
reference: format!("INV-{n}"),
amount_cents: i64::from(n),
amount_rate: 1e16,
metadata: r#"{"b":1,"a":{"y":2,"x":3}}"#.to_string(),
})
.await
.expect("insert");
for step in 1..=3 {
repo.update(
created.id,
&UpdateLgInvoice {
amount_cents: Patch::Set(i64::from(n) * 100 + step),
..Default::default()
},
)
.await
.expect("update");
}
ids.push(created.id);
}
for _ in 0..3 {
for id in &ids {
let report = repo.ledger_verify(*id).await.expect("verify");
assert!(report.is_intact(), "record {id} must verify: {report:?}");
assert_eq!(report.revisions_checked, 4);
}
}
}
#[tokio::test]
async fn duplicate_sequence_numbers_are_rejected_by_the_database() {
let pool = boot_pool("lg_unique_chain").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let mut conn = pool.get().await.expect("conn");
let result = diesel::sql_query(
"INSERT INTO _autumn_ledger_revisions \
(table_name, tenant_id, record_id, seq, op, actor, snapshot, valid_from, \
recorded_at, prev_hash, hash) \
SELECT table_name, tenant_id, record_id, seq, op, actor, snapshot, valid_from, \
recorded_at, prev_hash, 'duplicate' \
FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await;
assert!(
result.is_err(),
"the (table, tenant, record, seq) unique index must refuse a forked chain"
);
}
#[tokio::test]
async fn the_high_water_mark_upsert_refuses_to_move_backwards() {
let pool = boot_pool("lg_mark_monotonic").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"INSERT INTO _autumn_ledger_high_water \
(table_name, tenant_key, record_id, high_seq, head_hash, recorded_at) \
VALUES ('lg_invoices', '', ?, 1, 'stale', ?) \
ON CONFLICT (table_name, tenant_key, record_id) DO UPDATE SET \
high_seq = excluded.high_seq, \
head_hash = excluded.head_hash, \
recorded_at = excluded.recorded_at \
WHERE _autumn_ledger_high_water.high_seq < excluded.high_seq",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(now())
.execute(&mut *conn)
.await
.expect("the upsert itself must succeed — it simply must not lower the mark");
}
let mark = repo
.ledger_high_water(id)
.await
.expect("mark")
.expect("mark");
assert_eq!(
mark.seq, 3,
"a stale writer must not be able to lower the mark"
);
assert_ne!(mark.hash, "stale");
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn a_failed_mark_upsert_rolls_the_whole_append_back() {
let pool = boot_pool("lg_mark_rollback").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_freeze_mark BEFORE UPDATE ON _autumn_ledger_high_water \
BEGIN SELECT RAISE(ABORT, 'mark frozen'); END",
)
.await
.expect("freeze the mark");
}
let result = repo
.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(99),
..Default::default()
},
)
.await;
assert!(
result.is_err(),
"an append whose mark cannot be raised must fail rather than proceed"
);
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.len(),
3,
"the revision must roll back with the mark, not survive it: {:?}",
revisions.iter().map(|r| r.seq).collect::<Vec<_>>()
);
let mark = repo
.ledger_high_water(id)
.await
.expect("mark")
.expect("mark");
assert_eq!(mark.seq, 3);
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute("DROP TRIGGER lg_freeze_mark")
.await
.expect("unfreeze the mark");
}
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(99),
..Default::default()
},
)
.await
.expect("update");
let mark = repo
.ledger_high_water(id)
.await
.expect("mark")
.expect("mark");
assert_eq!(
mark.seq, 4,
"the rolled-back attempt burned no sequence number"
);
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn ledger_reads_fail_closed_across_tenants() {
let pool = boot_pool("lg_tenant").await;
let repo = PgLgTenantInvoiceRepository::with_pool_untracked(pool);
let id = with_tenant("tenant-a".to_string(), async {
repo.save(&NewLgTenantInvoice {
reference: "A-1".to_string(),
})
.await
.expect("insert as tenant-a")
.id
})
.await;
let mine = with_tenant("tenant-a".to_string(), async {
repo.ledger_revisions(id).await.expect("read as tenant-a")
})
.await;
assert_eq!(mine.len(), 1);
assert_eq!(mine[0].tenant_id.as_deref(), Some("tenant-a"));
let theirs = with_tenant("tenant-b".to_string(), async {
repo.ledger_revisions(id).await.expect("read as tenant-b")
})
.await;
assert!(
theirs.is_empty(),
"a ledgered read must not leak another tenant's revisions: {theirs:?}"
);
let reconstructed = with_tenant("tenant-b".to_string(), async {
repo.ledger_as_of_at(id, LedgerAsOf::default())
.await
.expect("as-of as tenant-b")
})
.await;
assert!(
reconstructed.is_none(),
"as-of must fail closed across tenants"
);
}
#[tokio::test]
async fn restore_records_a_revision_and_keeps_as_of_true() {
let pool = boot_pool("lg_restore").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let id = write_three_revisions(&repo).await;
repo.delete_by_id(id).await.expect("soft delete");
repo.restore(id).await.expect("restore");
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.op).collect::<Vec<_>>(),
vec![
VersionOp::Insert,
VersionOp::Update,
VersionOp::Update,
VersionOp::Delete,
VersionOp::Update,
],
"restore appends an update revision"
);
let reconstructed = repo
.ledger_as_of_at(id, LedgerAsOf::default())
.await
.expect("as-of")
.expect("state exists");
assert!(
reconstructed.deleted_at.is_none(),
"the restore revision must snapshot the undeleted row"
);
let live = repo
.find_by_id(id)
.await
.expect("live read")
.expect("live row");
assert_same_record(&reconstructed, &live, "as-of(now) must equal the live row");
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn verify_detects_a_truncated_tail() {
let pool = boot_pool("lg_truncate").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("lop off the newest revision");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a truncated tail must be detected");
assert_eq!(
broken.kind,
LedgerBreak::MissingRevision,
"the mark outlives the row it names, so the break is the absent revision \
rather than merely a live row that disagrees"
);
assert_eq!(
broken.seq, 3,
"reported at the sequence number that is gone"
);
resync_high_water_mark(&pool, "lg_invoices", id).await;
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the live row must still expose the truncation");
assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
assert_eq!(broken.seq, 2, "reported at the surviving head");
}
#[tokio::test]
async fn a_post_truncation_append_leaves_a_gap_verify_reports() {
let pool = boot_pool("lg_truncate_then_append").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("lop off the newest revision");
}
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(4_242),
..Default::default()
},
)
.await
.expect("an ordinary write lands after the truncation");
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![1, 2, 4],
"the append must allocate past the deleted sequence number, not re-use it"
);
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the gap the append left must be reported");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 3);
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(4_343),
..Default::default()
},
)
.await
.expect("more ordinary traffic");
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the gap must not heal");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 3);
}
#[tokio::test]
async fn ledger_pin_returns_the_head_and_the_mark_from_one_snapshot() {
let pool = boot_pool("lg_pin").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let empty = repo.ledger_pin(9_999).await.expect("pin");
assert!(empty.head.is_none() && empty.high_water.is_none());
let id = write_three_revisions(&repo).await;
let pin = repo.ledger_pin(id).await.expect("pin");
let head = pin.head.expect("a written record has a head");
let mark = pin.high_water.expect("and a mark");
assert_eq!(head.seq, 3);
assert_eq!(
mark.seq, head.seq,
"the pair agrees because it is one snapshot"
);
assert_eq!(mark.hash, head.hash);
assert_eq!(mark.recorded_at, head.recorded_at);
assert_eq!(repo.ledger_head(id).await.expect("head"), Some(head));
assert_eq!(repo.ledger_high_water(id).await.expect("mark"), Some(mark));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("lop off the newest revision");
}
let pin = repo.ledger_pin(id).await.expect("pin");
assert_eq!(pin.head.expect("head").seq, 2);
assert_eq!(
pin.high_water.expect("mark").seq,
3,
"the mark outlives the revision it names, and the pair shows it"
);
}
#[tokio::test]
async fn tampering_with_the_high_water_mark_is_itself_detected() {
let pool = boot_pool("lg_mark_tamper").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let mark = repo
.ledger_high_water(id)
.await
.expect("mark")
.expect("a written record has a mark");
assert_eq!(mark.seq, 3, "the mark tracks every append");
let head = repo
.ledger_head(id)
.await
.expect("head")
.expect("a written record has a head");
assert_eq!(mark.hash, head.hash, "the mark names the head revision");
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET high_seq = 1 \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("roll the mark back");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a rolled-back mark must be detected");
assert_eq!(broken.kind, LedgerBreak::HighWaterBehind);
resync_high_water_mark(&pool, "lg_invoices", id).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET head_hash = 'deadbeef' \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("rewrite the mark's hash");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a rewritten mark must be detected");
assert_eq!(broken.kind, LedgerBreak::HighWaterMismatch);
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_high_water \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("delete the mark");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a deleted mark must be detected");
assert_eq!(broken.kind, LedgerBreak::HighWaterMissing);
resync_high_water_mark(&pool, "lg_invoices", id).await;
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn a_wholly_erased_chain_is_reported_against_its_surviving_mark() {
let pool = boot_pool("lg_erased_marked").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("erase the whole chain");
}
let report = repo.ledger_verify(id).await.expect("verify");
assert_eq!(report.revisions_checked, 0);
let broken = report.broken.expect("an erased chain must be reported");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 1);
}
#[tokio::test]
async fn a_row_with_no_chain_is_not_reported_as_tampering() {
let pool = boot_pool("lg_erased").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("leave the record with no chain");
}
resync_high_water_mark(&pool, "lg_invoices", id).await;
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(
report.revisions_checked, 0,
"the empty chain is what the caller inspects, not an accusation"
);
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(42),
..Default::default()
},
)
.await
.expect("update");
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 1);
}
#[tokio::test]
async fn a_pinned_head_detects_a_correctly_hashed_forgery() {
let pool = boot_pool("lg_pinned_head").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let pinned = repo
.ledger_head(id)
.await
.expect("head")
.expect("a written record has a head");
let head_revision = repo
.ledger_revisions(id)
.await
.expect("revisions")
.pop()
.expect("head revision");
let mut snapshot = head_revision.snapshot.clone();
snapshot["amount_cents"] = serde_json::json!(999_999);
let recorded_at = autumn_web::ledger::truncate_to_micros(now());
let forged_hash = autumn_web::ledger::revision_hash(&autumn_web::ledger::RevisionHashInput {
prev_hash: Some(head_revision.hash.as_str()),
table_name: "lg_invoices",
tenant_id: None,
record_id: id,
seq: head_revision.seq + 1,
op: VersionOp::Update,
actor: "mallory",
request_id: None,
snapshot: &snapshot,
valid_from: recorded_at,
recorded_at,
});
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"INSERT INTO _autumn_ledger_revisions \
(table_name, tenant_id, record_id, seq, op, actor, request_id, snapshot, \
valid_from, recorded_at, prev_hash, hash) \
VALUES ('lg_invoices', NULL, ?, ?, 'update', 'mallory', NULL, ?, ?, ?, ?, ?)",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(head_revision.seq + 1)
.bind::<diesel::sql_types::Text, _>(autumn_web::ledger::canonical_json(&snapshot))
.bind::<diesel::sql_types::TimestamptzSqlite, _>(recorded_at)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(recorded_at)
.bind::<diesel::sql_types::Text, _>(head_revision.hash.clone())
.bind::<diesel::sql_types::Text, _>(forged_hash)
.execute(&mut *conn)
.await
.expect("append a well-formed forgery");
diesel::sql_query("UPDATE lg_invoices SET amount_cents = 999999 WHERE id = ?")
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("rewrite the live row to match");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("an append the mark did not authorise must be detected");
assert_eq!(broken.kind, LedgerBreak::HighWaterBehind);
assert_eq!(broken.seq, head_revision.seq + 1);
resync_high_water_mark(&pool, "lg_invoices", id).await;
assert!(
repo.ledger_verify(id).await.expect("verify").is_intact(),
"a correctly-hashed, live-consistent, mark-consistent append is invisible \
from inside the database"
);
let now_head = repo.ledger_head(id).await.expect("head").expect("head");
assert_ne!(
now_head.hash, pinned.hash,
"a head pinned outside the database must disagree after a forgery"
);
assert_eq!(now_head.seq, pinned.seq + 1);
}
#[tokio::test]
async fn transaction_time_never_moves_backwards_along_a_chain() {
let pool = boot_pool("lg_txn_time_monotonic").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let recorded: Vec<DateTime<Utc>> = repo
.ledger_revisions(id)
.await
.expect("revisions")
.iter()
.map(|r| r.recorded_at)
.collect();
assert!(
recorded.windows(2).all(|w| w[0] <= w[1]),
"transaction time must be non-decreasing along a chain: {recorded:?}"
);
let ahead = autumn_web::ledger::truncate_to_micros(now() + Duration::minutes(30));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("lop off the newest revision");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET recorded_at = ? \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(ahead)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("move the record's transaction-time floor forward");
}
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(7),
..Default::default()
},
)
.await
.expect("update");
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let head = revisions.last().expect("head");
assert!(
head.recorded_at >= ahead,
"a write behind the chain's floor must be clamped up to it, not written \
behind it: {} < {ahead}",
head.recorded_at
);
let recorded: Vec<DateTime<Utc>> = revisions.iter().map(|r| r.recorded_at).collect();
assert!(
recorded.windows(2).all(|w| w[0] <= w[1]),
"still non-decreasing across the gap the deleted revision left: {recorded:?}"
);
assert_eq!(
head.compute_hash(),
head.hash,
"the stored transaction time must be the value that was hashed"
);
}
#[tokio::test]
async fn a_transaction_time_floor_far_ahead_of_the_database_refuses_the_write() {
let pool = boot_pool("lg_txn_time_ratchet").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let far = autumn_web::ledger::truncate_to_micros(now() + Duration::days(365 * 900));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("lop off the newest revision");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET recorded_at = ? \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(far)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("poison the record's transaction-time floor");
}
let result = repo
.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(7),
..Default::default()
},
)
.await;
assert!(
result.is_err(),
"a poisoned floor must refuse the write rather than hash a far-future \
instant into the chain"
);
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(revisions.len(), 2);
assert!(revisions.iter().all(|r| r.recorded_at < far));
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the truncation is still reported");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
}
#[tokio::test]
async fn a_rewritten_mark_instant_is_refused_like_any_other_mark_rewrite() {
let pool = boot_pool("lg_txn_time_mark_rewrite").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let ahead = autumn_web::ledger::truncate_to_micros(now() + Duration::minutes(45));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET recorded_at = ? \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(ahead)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("rewrite the mark's instant, leaving seq and hash alone");
}
assert_eq!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("reported")
.kind,
LedgerBreak::HighWaterMismatch
);
assert!(
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(11),
..Default::default()
},
)
.await
.is_err(),
"the append must refuse rather than overwrite the mark and launder the \
accusation away"
);
assert_eq!(repo.ledger_revisions(id).await.expect("revisions").len(), 3);
assert_eq!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("still reported")
.kind,
LedgerBreak::HighWaterMismatch
);
}
#[tokio::test]
async fn a_mark_behind_the_head_cannot_steer_the_next_revisions_time() {
let pool = boot_pool("lg_txn_time_mark_ignored").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let ahead = autumn_web::ledger::truncate_to_micros(now() + Duration::minutes(45));
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET high_seq = 2, \
head_hash = (SELECT hash FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2), \
recorded_at = ? \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(ahead)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("leave the mark behind, carrying a bogus instant");
}
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(11),
..Default::default()
},
)
.await
.expect("a mark behind the head must not block writes");
let head = repo
.ledger_revisions(id)
.await
.expect("revisions")
.pop()
.expect("head");
assert!(
head.recorded_at < ahead,
"the mark's unhashed instant must not become the floor while the head \
revision that carries a hashed one is still there: {} >= {ahead}",
head.recorded_at
);
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn an_as_of_query_never_returns_a_revision_recorded_after_the_instant() {
let pool = boot_pool("lg_txn_time_as_of").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgInvoice {
reference: "INV-AS-OF".to_string(),
amount_cents: 1,
amount_rate: 1.5,
metadata: "{}".to_string(),
})
.await
.expect("insert");
for step in 2..=4 {
tick().await;
repo.update(
created.id,
&UpdateLgInvoice {
amount_cents: Patch::Set(step),
..Default::default()
},
)
.await
.expect("update");
}
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
assert_eq!(revisions.len(), 4);
assert!(
revisions
.windows(2)
.all(|w| w[0].recorded_at < w[1].recorded_at),
"the writes are spaced, so each revision has its own instant"
);
for (index, revision) in revisions.iter().enumerate() {
let at_instant = repo
.ledger_as_of(created.id, revision.recorded_at)
.await
.expect("as-of")
.expect("the record existed");
assert_eq!(
serde_json::json!(at_instant.amount_cents),
revision.snapshot["amount_cents"],
"as-of at revision {}'s own instant must return revision {}",
revision.seq,
revision.seq,
);
let just_before = revision.recorded_at - Duration::microseconds(1);
let earlier = repo
.ledger_as_of(created.id, just_before)
.await
.expect("as-of");
match index.checked_sub(1).and_then(|prev| revisions.get(prev)) {
None => assert!(
earlier.is_none(),
"before the insert the record did not exist yet"
),
Some(previous) => {
let earlier = earlier.expect("the record existed");
assert_eq!(
serde_json::json!(earlier.amount_cents),
previous.snapshot["amount_cents"],
"as-of just before revision {} must return revision {}, never a \
revision recorded after the instant asked about",
revision.seq,
previous.seq,
);
}
}
}
}
#[tokio::test]
async fn verify_detects_a_transaction_time_that_moves_backwards() {
let pool = boot_pool("lg_txn_time_regression").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let head = revisions.last().expect("head");
let backdated = revisions[1].recorded_at - Duration::seconds(1);
let rehashed = autumn_web::ledger::revision_hash(&autumn_web::ledger::RevisionHashInput {
prev_hash: head.prev_hash.as_deref(),
table_name: "lg_invoices",
tenant_id: None,
record_id: id,
seq: head.seq,
op: head.op,
actor: head.actor.as_str(),
request_id: head.request_id.as_deref(),
snapshot: &head.snapshot,
valid_from: head.valid_from,
recorded_at: backdated,
});
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET recorded_at = ?, hash = ? \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::TimestamptzSqlite, _>(backdated)
.bind::<diesel::sql_types::Text, _>(rehashed)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(head.seq)
.execute(&mut *conn)
.await
.expect("back-date the newest revision");
}
resync_high_water_mark(&pool, "lg_invoices", id).await;
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a backwards transaction time must be detected");
assert_eq!(broken.kind, LedgerBreak::RecordedAtRegression);
assert_eq!(broken.seq, head.seq);
}
#[tokio::test]
async fn an_append_refuses_to_re_create_a_deleted_high_water_mark() {
let pool = boot_pool("lg_mark_recreate").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
for sql in [
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
"DELETE FROM _autumn_ledger_high_water \
WHERE table_name = 'lg_invoices' AND record_id = ?",
] {
diesel::sql_query(sql)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("cover both tables");
}
}
let result = repo
.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await;
assert!(
result.is_err(),
"an append over a chain whose mark is gone must refuse rather than \
re-create the mark and launder the truncation away"
);
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(revisions.len(), 2);
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the missing mark is still reported");
assert_eq!(broken.kind, LedgerBreak::HighWaterMissing);
}
#[tokio::test]
async fn an_append_refuses_when_the_mark_and_the_head_disagree() {
let pool = boot_pool("lg_mark_disagree").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET head_hash = 'deadbeef' \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("rewrite the mark");
}
let result = repo
.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await;
assert!(result.is_err(), "the disagreement must stop the write");
assert_eq!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("still reported")
.kind,
LedgerBreak::HighWaterMismatch
);
}
#[tokio::test]
async fn a_write_after_a_wholly_erased_chain_leaves_permanent_evidence() {
let pool = boot_pool("lg_erased_then_write").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("erase the whole chain");
}
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await
.expect("ordinary traffic continues");
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![4],
"the append allocates above the surviving mark, not from 1"
);
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("the erasure must still be reported after the write");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 1);
}
#[tokio::test]
async fn a_non_positive_high_water_mark_is_refused_rather_than_overwritten() {
let pool = boot_pool("lg_mark_non_positive").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
let refused = diesel::sql_query(
"INSERT INTO _autumn_ledger_high_water \
(table_name, tenant_key, record_id, high_seq, head_hash, recorded_at) \
VALUES ('lg_invoices', '', 424242, 0, 'planted', '2026-01-01T00:00:00Z')",
)
.execute(&mut *conn)
.await;
assert!(
refused.is_err(),
"CHECK (high_seq >= 1) must make a non-positive mark unrepresentable"
);
}
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"ALTER TABLE _autumn_ledger_high_water RENAME TO _hw_old; \
CREATE TABLE _autumn_ledger_high_water ( \
table_name TEXT NOT NULL, tenant_key TEXT NOT NULL, \
record_id BIGINT NOT NULL, high_seq BIGINT NOT NULL, \
head_hash TEXT NOT NULL, recorded_at TEXT NOT NULL, \
PRIMARY KEY (table_name, tenant_key, record_id)); \
INSERT INTO _autumn_ledger_high_water SELECT * FROM _hw_old; \
DROP TABLE _hw_old;",
)
.await
.expect("rebuild the mark table without its CHECK");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET high_seq = 0 \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("plant a non-positive mark");
}
assert!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.is_some(),
"a mark below the chain head is reported"
);
assert!(
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await
.is_err(),
"the append must refuse rather than allocate past the mark and raise it"
);
assert_eq!(
repo.ledger_revisions(id).await.expect("revisions").len(),
3,
"nothing was written"
);
assert!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.is_some(),
"and the accusation survives"
);
}
#[tokio::test]
async fn a_mark_behind_the_head_is_reported_but_still_writable() {
let pool = boot_pool("lg_mark_behind").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_high_water SET high_seq = 2, \
head_hash = (SELECT hash FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2), \
recorded_at = (SELECT recorded_at FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2) \
WHERE table_name = 'lg_invoices' AND record_id = ?",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("leave the mark one behind, as an old writer would");
}
assert_eq!(
repo.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("reported")
.kind,
LedgerBreak::HighWaterBehind
);
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await
.expect("a mark behind the head must not block writes");
assert!(
repo.ledger_verify(id).await.expect("verify").is_intact(),
"the record's next write re-establishes the mark above both"
);
}
#[tokio::test]
async fn a_truncation_covered_up_in_both_tables_is_only_caught_by_a_pinned_head() {
let pool = boot_pool("lg_residual").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let pinned = repo
.ledger_head(id)
.await
.expect("head")
.expect("head")
.hash;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("delete the newest revision");
}
resync_high_water_mark(&pool, "lg_invoices", id).await;
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(1),
..Default::default()
},
)
.await
.expect("ordinary traffic continues");
assert!(
repo.ledger_verify(id).await.expect("verify").is_intact(),
"documented residual: consistent tampering across both tables is not \
visible from inside the database"
);
let now_head = repo.ledger_head(id).await.expect("head").expect("head");
assert_ne!(
now_head.hash, pinned,
"a head pinned outside the database is what still disagrees"
);
}
#[tokio::test]
async fn the_migration_backfills_a_mark_for_every_chain_that_already_exists() {
let pool = boot_pool("lg_backfill").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let mut ids = Vec::new();
for n in 0..2 {
let created = repo
.save(&NewLgInvoice {
reference: format!("PRE-{n}"),
amount_cents: 1,
amount_rate: 1.0,
metadata: "{}".to_string(),
})
.await
.expect("insert");
for step in 0..=n {
repo.update(
created.id,
&UpdateLgInvoice {
amount_cents: Patch::Set(i64::from(step) + 2),
..Default::default()
},
)
.await
.expect("update");
}
ids.push(created.id);
}
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute("DELETE FROM _autumn_ledger_high_water")
.await
.expect("un-mark every chain");
conn.batch_execute(LEDGER_HIGH_WATER_UP)
.await
.expect("apply the high-water migration over an existing ledger");
}
for (n, id) in ids.iter().enumerate() {
let mark = repo
.ledger_high_water(*id)
.await
.expect("mark")
.expect("the backfill must have marked this chain");
let head = repo.ledger_head(*id).await.expect("head").expect("head");
assert_eq!(mark.seq, head.seq, "record {id} ({n})");
assert_eq!(mark.hash, head.hash);
assert_eq!(mark.recorded_at, head.recorded_at);
assert!(
repo.ledger_verify(*id).await.expect("verify").is_intact(),
"a backfilled chain must not be accused"
);
}
repo.update(
ids[0],
&UpdateLgInvoice {
amount_cents: Patch::Set(99),
..Default::default()
},
)
.await
.expect("update after the backfill");
assert!(
repo.ledger_verify(ids[0])
.await
.expect("verify")
.is_intact()
);
}
#[tokio::test]
async fn high_water_marks_are_per_tenant() {
let pool = boot_pool("lg_tenant_mark").await;
let repo = PgLgTenantInvoiceRepository::with_pool_untracked(pool.clone());
let a_id = with_tenant("tenant-a".to_string(), async {
let created = repo
.save(&NewLgTenantInvoice {
reference: "A-1".to_string(),
})
.await
.expect("insert as tenant-a");
for step in 0..2 {
repo.update(
created.id,
&UpdateLgTenantInvoice {
reference: Patch::Set(format!("A-1-{step}")),
},
)
.await
.expect("update as tenant-a");
}
created.id
})
.await;
let b_id = with_tenant("tenant-b".to_string(), async {
repo.save(&NewLgTenantInvoice {
reference: "B-1".to_string(),
})
.await
.expect("insert as tenant-b")
.id
})
.await;
with_tenant("tenant-a".to_string(), async {
let mark = repo
.ledger_high_water(a_id)
.await
.expect("mark")
.expect("tenant-a has a mark");
assert_eq!(mark.seq, 3, "tenant-a's own chain length, not tenant-b's");
assert!(repo.ledger_verify(a_id).await.expect("verify").is_intact());
})
.await;
with_tenant("tenant-b".to_string(), async {
let mark = repo
.ledger_high_water(b_id)
.await
.expect("mark")
.expect("tenant-b has a mark");
assert_eq!(mark.seq, 1);
assert!(repo.ledger_verify(b_id).await.expect("verify").is_intact());
if a_id == b_id {
assert_eq!(
repo.ledger_high_water(a_id)
.await
.expect("mark")
.unwrap()
.seq,
1
);
}
})
.await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_tenant_invoices' AND tenant_id = 'tenant-a' \
AND record_id = ? AND seq = 3",
)
.bind::<diesel::sql_types::BigInt, _>(a_id)
.execute(&mut *conn)
.await
.expect("truncate tenant-a's chain");
}
with_tenant("tenant-a".to_string(), async {
let broken = repo
.ledger_verify(a_id)
.await
.expect("verify")
.broken
.expect("tenant-a's truncation must be reported");
assert_eq!(broken.kind, LedgerBreak::MissingRevision);
assert_eq!(broken.seq, 3);
})
.await;
with_tenant("tenant-b".to_string(), async {
assert!(
repo.ledger_verify(b_id).await.expect("verify").is_intact(),
"tenant-b must not inherit tenant-a's accusation"
);
})
.await;
}
#[tokio::test]
async fn bulk_writes_chain_every_row_independently() {
let pool = boot_pool("lg_bulk").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let created = repo
.save_many(&[
NewLgInvoice {
reference: "BULK-1".to_string(),
amount_cents: 1,
amount_rate: 1e16,
metadata: "{}".to_string(),
},
NewLgInvoice {
reference: "BULK-2".to_string(),
amount_cents: 2,
amount_rate: 2.5,
metadata: "{}".to_string(),
},
])
.await
.expect("save_many");
assert_eq!(created.len(), 2);
let ids: Vec<i64> = created.iter().map(|r| r.id).collect();
for id in &ids {
let revisions = repo.ledger_revisions(*id).await.expect("revisions");
assert_eq!(
revisions.len(),
1,
"each bulk-inserted row opens its own chain"
);
assert_eq!(revisions[0].seq, 1);
assert_eq!(revisions[0].prev_hash, None);
assert!(repo.ledger_verify(*id).await.expect("verify").is_intact());
}
repo.delete_many(&ids).await.expect("delete_many");
for id in &ids {
let revisions = repo.ledger_revisions(*id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.op).collect::<Vec<_>>(),
vec![VersionOp::Insert, VersionOp::Delete],
"a bulk soft-delete appends one revision per row"
);
assert_eq!(revisions[1].seq, 2);
assert_eq!(
revisions[1].prev_hash.as_deref(),
Some(revisions[0].hash.as_str())
);
let report = repo.ledger_verify(*id).await.expect("verify");
assert!(report.is_intact(), "record {id}: {report:?}");
}
}
#[tokio::test]
async fn a_truncated_tail_is_detected_when_only_an_encrypted_column_changed() {
let pool = boot_pool("lg_vault_tail").await;
let repo = PgLgVaultNoteRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgVaultNote {
body: "public".to_string(),
secret: "before".to_string(),
})
.await
.expect("insert");
repo.update(
created.id,
&UpdateLgVaultNote {
secret: Patch::Set("after".to_string()),
..Default::default()
},
)
.await
.expect("update an encrypted column only");
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
assert_eq!(revisions.len(), 2);
let stored_secret = revisions[1].snapshot["secret"]
.as_str()
.expect("the snapshot carries the encrypted column");
assert_ne!(
stored_secret, "after",
"the ledger must not store the plaintext of an encrypted column"
);
assert!(
repo.ledger_verify(created.id)
.await
.expect("verify")
.is_intact(),
"no false positive on an intact chain with an encrypted column"
);
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_vault_notes' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(created.id)
.execute(&mut *conn)
.await
.expect("lop off the revision that changed only the encrypted column");
}
resync_high_water_mark(&pool, "lg_vault_notes", created.id).await;
let broken = repo
.ledger_verify(created.id)
.await
.expect("verify")
.broken
.expect("an encrypted-column-only truncation must still be detected");
assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
assert_eq!(broken.seq, 1);
}
#[tokio::test]
async fn an_encrypted_column_does_not_cause_a_false_positive() {
let pool = boot_pool("lg_vault_intact").await;
let repo = PgLgVaultNoteRepository::with_pool_untracked(pool);
let created = repo
.save(&NewLgVaultNote {
body: "public".to_string(),
secret: "s1".to_string(),
})
.await
.expect("insert");
for secret in ["s2", "s3"] {
repo.update(
created.id,
&UpdateLgVaultNote {
secret: Patch::Set(secret.to_string()),
..Default::default()
},
)
.await
.expect("update");
}
repo.delete_by_id(created.id).await.expect("soft delete");
repo.restore(created.id).await.expect("restore");
for _ in 0..3 {
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 5);
}
let reconstructed = repo
.ledger_as_of_at(created.id, LedgerAsOf::default())
.await
.expect("as-of")
.expect("state");
assert_eq!(reconstructed.secret, "s3");
}
#[tokio::test]
async fn a_hard_parent_delete_is_refused_rather_than_erasing_a_ledgered_child() {
let pool = boot_pool("lg_cascade").await;
let parents = PgLgCascadeParentRepository::with_pool_untracked(pool.clone());
let children = PgLgCascadeChildRepository::with_pool_untracked(pool);
let parent = parents
.save(&NewLgCascadeParent {
name: "p".to_string(),
})
.await
.expect("insert parent");
let child = children
.save(&NewLgCascadeChild {
parent_id: parent.id,
label: "c".to_string(),
})
.await
.expect("insert ledgered child");
let err = parents
.delete_by_id(parent.id)
.await
.expect_err("a hard parent delete must not erase a ledgered child");
let typed = err
.downcast_chain_ref::<autumn_web::ledger::LedgerError>()
.expect("the refusal is a typed LedgerError, not a foreign-key violation");
assert!(
matches!(
typed,
autumn_web::ledger::LedgerError::HardDeleteCascade { record_id, .. }
if *record_id == child.id
),
"{typed:?}"
);
let rendered = typed.to_string();
assert!(rendered.contains("soft_delete"), "{rendered}");
let live = children
.find_by_id(child.id)
.await
.expect("read child")
.expect("the child survives a refused cascade");
assert!(live.deleted_at.is_none());
let report = children.ledger_verify(child.id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 1);
}
#[tokio::test]
async fn a_truncated_tail_is_detected_when_only_a_hidden_column_changed() {
let pool = boot_pool("lg_hidden_tail").await;
let repo = PgLgSecretNoteRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgSecretNote {
body: "public".to_string(),
internal_note: "before".to_string(),
})
.await
.expect("insert");
repo.update(
created.id,
&UpdateLgSecretNote {
internal_note: Patch::Set("after".to_string()),
..Default::default()
},
)
.await
.expect("update a hidden column only");
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
assert_eq!(revisions.len(), 2);
assert_eq!(
revisions[0].snapshot["body"], revisions[1].snapshot["body"],
"the public column is unchanged across the two revisions"
);
assert_ne!(
revisions[0].snapshot["internal_note"], revisions[1].snapshot["internal_note"],
"the hidden column is what changed — and the snapshot carries it"
);
assert!(
repo.ledger_verify(created.id)
.await
.expect("verify")
.is_intact(),
"no false positive on an intact chain with a hidden column"
);
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"DELETE FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_secret_notes' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(created.id)
.execute(&mut *conn)
.await
.expect("lop off the revision that changed only the hidden column");
}
resync_high_water_mark(&pool, "lg_secret_notes", created.id).await;
let broken = repo
.ledger_verify(created.id)
.await
.expect("verify")
.broken
.expect("a hidden-column-only truncation must still be detected");
assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
assert_eq!(broken.seq, 1);
}
#[tokio::test]
async fn a_hidden_column_does_not_cause_a_false_positive() {
let pool = boot_pool("lg_hidden_intact").await;
let repo = PgLgSecretNoteRepository::with_pool_untracked(pool);
let created = repo
.save(&NewLgSecretNote {
body: "public".to_string(),
internal_note: "secret".to_string(),
})
.await
.expect("insert");
for note in ["secret-2", "secret-3"] {
repo.update(
created.id,
&UpdateLgSecretNote {
internal_note: Patch::Set(note.to_string()),
..Default::default()
},
)
.await
.expect("update");
}
repo.delete_by_id(created.id).await.expect("soft delete");
repo.restore(created.id).await.expect("restore");
for _ in 0..3 {
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 5);
}
let reconstructed = repo
.ledger_as_of_at(created.id, LedgerAsOf::default())
.await
.expect("as-of")
.expect("state");
assert_eq!(reconstructed.internal_note, "secret-3");
}
#[tokio::test]
async fn a_declared_valid_time_column_separates_the_two_axes() {
let pool = boot_pool("lg_valid_time").await;
let repo = PgLgEffectiveNoteRepository::with_pool_untracked(pool);
let effective = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time");
let created = repo
.save(&NewLgEffectiveNote {
body: "v1".to_string(),
effective_at: effective,
})
.await
.expect("insert");
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
assert_eq!(revisions.len(), 1);
assert_eq!(
revisions[0].valid_from,
effective.and_utc(),
"valid time comes from the declared column, not the clock"
);
assert!(
revisions[0].recorded_at > revisions[0].valid_from,
"transaction time is now; valid time is back in January"
);
let earlier = chrono::NaiveDate::from_ymd_opt(2025, 6, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time");
repo.update(
created.id,
&UpdateLgEffectiveNote {
body: Patch::Set("v2".to_string()),
effective_at: Patch::Set(earlier),
},
)
.await
.expect("correction");
let live = repo
.find_by_id(created.id)
.await
.expect("live read")
.expect("live row");
let latest = repo
.ledger_as_of(created.id, now() + Duration::seconds(1))
.await
.expect("as-of")
.expect("state");
assert_eq!(latest, live, "as-of(now) must equal the live row");
assert_eq!(latest.body, "v2");
let mid_2025 = chrono::NaiveDate::from_ymd_opt(2025, 8, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time")
.and_utc();
let then = repo
.ledger_as_of_at(created.id, LedgerAsOf::valid(mid_2025))
.await
.expect("as-of")
.expect("state");
assert_eq!(
then.body, "v2",
"the back-dated correction governs August 2025"
);
let mid_2026 = chrono::NaiveDate::from_ymd_opt(2026, 6, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time")
.and_utc();
let later = repo
.ledger_as_of_at(created.id, LedgerAsOf::valid(mid_2026))
.await
.expect("as-of")
.expect("state");
assert_eq!(
later.body, "v2",
"a correction supersedes what it corrects from its own effective_at onward"
);
assert!(
repo.ledger_verify(created.id)
.await
.expect("verify")
.is_intact()
);
}
#[tokio::test]
async fn a_non_finite_float_cannot_be_written_to_a_ledgered_row() {
let pool = boot_pool("lg_non_finite").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let err = repo
.save(&NewLgInvoice {
reference: "INV-INF".to_string(),
amount_cents: 1,
amount_rate: f64::INFINITY,
metadata: "{}".to_string(),
})
.await
.expect_err("a snapshot cannot hold infinity");
let text = err.to_string();
assert!(
text.contains("amount_rate") && text.contains("lg_invoices"),
"the error names the table and the column: {text}"
);
let all = repo.find_all().await.expect("list");
assert!(all.is_empty(), "the refused write left no row behind");
}
#[tokio::test]
async fn a_non_finite_update_is_refused_and_leaves_the_chain_intact() {
let pool = boot_pool("lg_non_finite_update").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let id = write_three_revisions(&repo).await;
repo.update(
id,
&UpdateLgInvoice {
amount_rate: Patch::Set(f64::NEG_INFINITY),
..Default::default()
},
)
.await
.expect_err("infinity must be refused");
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 3, "no revision was appended");
let live = repo.find_by_id(id).await.expect("read").expect("row");
assert!(live.amount_rate.is_finite(), "the row kept its old value");
}
#[tokio::test]
async fn a_trigger_written_infinity_is_refused_and_leaves_the_chain_intact() {
let pool = boot_pool("lg_non_finite_trigger").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_invoices_inf AFTER UPDATE OF reference ON lg_invoices \
WHEN NEW.reference = 'INV-INF' \
BEGIN UPDATE lg_invoices SET amount_rate = 9e999 WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
let err = repo
.update(
id,
&UpdateLgInvoice {
reference: Patch::Set("INV-INF".to_string()),
..Default::default()
},
)
.await
.expect_err("the trigger wrote infinity");
assert!(err.to_string().contains("amount_rate"), "{err}");
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 3, "no revision was appended");
let live = repo.find_by_id(id).await.expect("read").expect("row");
assert!(live.amount_rate.is_finite(), "the update rolled back");
}
#[tokio::test]
async fn a_trigger_written_infinity_during_restore_is_refused() {
let pool = boot_pool("lg_non_finite_restore_trigger").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
repo.delete_by_id(id).await.expect("delete");
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_invoices_restore_inf AFTER UPDATE OF deleted_at ON lg_invoices \
WHEN NEW.deleted_at IS NULL \
BEGIN UPDATE lg_invoices SET amount_rate = 9e999 WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
let err = repo
.restore(id)
.await
.expect_err("the trigger wrote infinity");
assert!(err.to_string().contains("amount_rate"), "{err}");
assert!(
repo.find_by_id(id).await.expect("read").is_none(),
"the restore rolled back"
);
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 4, "no revision was appended");
}
#[tokio::test]
async fn a_legacy_non_finite_row_can_still_be_deleted_and_restored() {
let pool = boot_pool("lg_non_finite_legacy").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query("UPDATE lg_invoices SET amount_rate = 9e999 WHERE id = ?")
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("a legacy value, written out of band");
}
repo.delete_by_id(id).await.expect("delete a legacy row");
repo.restore(id).await.expect("restore a legacy row");
let live = repo.find_by_id(id).await.expect("read").expect("row");
assert!(live.amount_rate.is_infinite());
}
#[tokio::test]
async fn a_trigger_written_valid_time_is_the_revisions_valid_time() {
let pool = boot_pool("lg_valid_time_trigger").await;
let repo = PgLgEffectiveNoteRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgEffectiveNote {
body: "v1".to_string(),
effective_at: chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time"),
})
.await
.expect("insert");
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_effective_touch AFTER UPDATE OF body ON lg_effective_notes \
WHEN NEW.body = 'v2' \
BEGIN UPDATE lg_effective_notes SET effective_at = '2024-03-01 00:00:00' \
WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
repo.update(
created.id,
&UpdateLgEffectiveNote {
body: Patch::Set("v2".to_string()),
..Default::default()
},
)
.await
.expect("update");
let rewritten = chrono::NaiveDate::from_ymd_opt(2024, 3, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time");
let live = repo
.find_by_id(created.id)
.await
.expect("read")
.expect("row");
assert_eq!(live.effective_at, rewritten, "the trigger ran");
let head = repo
.ledger_revisions(created.id)
.await
.expect("revisions")
.pop()
.expect("head");
assert_eq!(head.valid_from, rewritten.and_utc());
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
}
#[test]
fn the_generated_check_names_the_first_non_finite_column() {
let mut invoice = LgInvoice {
id: 1,
reference: "R".to_string(),
amount_cents: 1,
amount_rate: 1.0,
metadata: "{}".to_string(),
deleted_at: None,
};
assert_eq!(invoice.__autumn_ledger_non_finite_column(), None);
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
invoice.amount_rate = bad;
assert_eq!(
invoice.__autumn_ledger_non_finite_column(),
Some("amount_rate")
);
}
}
#[tokio::test]
async fn delete_and_restore_do_not_inherit_a_stale_valid_time() {
let pool = boot_pool("lg_valid_time_delete").await;
let repo = PgLgEffectiveNoteRepository::with_pool_untracked(pool);
let effective = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time");
let created = repo
.save(&NewLgEffectiveNote {
body: "v1".to_string(),
effective_at: effective,
})
.await
.expect("insert");
repo.delete_by_id(created.id).await.expect("soft delete");
repo.restore(created.id).await.expect("restore");
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
assert_eq!(revisions.len(), 3);
for revision in &revisions[1..] {
assert_eq!(
revision.valid_from, revision.recorded_at,
"a delete or restore is valid from the instant it was made"
);
}
let february = chrono::NaiveDate::from_ymd_opt(2026, 2, 1)
.expect("date")
.and_hms_opt(0, 0, 0)
.expect("time")
.and_utc();
let then = repo
.ledger_as_of_at(created.id, LedgerAsOf::valid(february))
.await
.expect("as-of")
.expect("state");
assert!(
then.deleted_at.is_none(),
"February was before the deletion, so the row was not deleted yet"
);
assert!(
repo.ledger_verify(created.id)
.await
.expect("verify")
.is_intact()
);
}
async fn write_three_revisions(repo: &PgLgInvoiceRepository) -> i64 {
let created = repo
.save(&NewLgInvoice {
reference: "INV-X".to_string(),
amount_cents: 1,
amount_rate: 1e16,
metadata: r#"{"b":1,"a":{"y":2,"x":3}}"#.to_string(),
})
.await
.expect("insert");
for amount in [2, 3] {
tick().await;
repo.update(
created.id,
&UpdateLgInvoice {
amount_cents: Patch::Set(amount),
..Default::default()
},
)
.await
.expect("update");
}
created.id
}
#[tokio::test]
async fn a_ledgered_row_cannot_move_between_tenants() {
let pool = boot_pool("lg_tenant_move").await;
let repo = PgLgMovableInvoiceRepository::with_pool_untracked(pool);
let id = with_tenant("tenant-a".to_string(), async {
repo.save(&NewLgMovableInvoice {
reference: "A-1".to_string(),
tenant_id: "tenant-a".to_string(),
})
.await
.expect("insert as tenant-a")
.id
})
.await;
let err = repo
.across_tenants()
.update(
id,
&UpdateLgMovableInvoice {
tenant_id: Patch::Set("tenant-b".to_string()),
..Default::default()
},
)
.await
.expect_err("a ledgered row must not change tenant");
match err.downcast_chain_ref::<autumn_web::ledger::LedgerError>() {
Some(autumn_web::ledger::LedgerError::TenantChange { table, record_id }) => {
assert_eq!(table, "lg_movable_invoices");
assert_eq!(*record_id, id);
}
other => panic!("expected TenantChange, got {other:?}"),
}
repo.across_tenants()
.update(
id,
&UpdateLgMovableInvoice {
reference: Patch::Set("A-2".to_string()),
..Default::default()
},
)
.await
.expect("same-tenant update");
with_tenant("tenant-a".to_string(), async {
let live = repo
.find_by_id(id)
.await
.expect("read")
.expect("still tenant-a's");
assert_eq!(live.tenant_id, "tenant-a");
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 2);
})
.await;
with_tenant("tenant-b".to_string(), async {
assert!(repo.ledger_revisions(id).await.expect("read").is_empty());
})
.await;
}
fn assert_tenant_change(err: &autumn_web::AutumnError, id: i64) {
match err.downcast_chain_ref::<autumn_web::ledger::LedgerError>() {
Some(autumn_web::ledger::LedgerError::TenantChange { table, record_id }) => {
assert_eq!(table, "lg_movable_invoices");
assert_eq!(*record_id, id);
}
other => panic!("expected TenantChange, got {other:?}"),
}
}
async fn install_tenant_rewrite_trigger(pool: &SqlitePool, name: &str, event: &str, when: &str) {
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(&format!(
"CREATE TRIGGER {name} AFTER {event} ON lg_movable_invoices \
WHEN {when} \
BEGIN UPDATE lg_movable_invoices SET tenant_id = 'tenant-b' WHERE id = NEW.id; END"
))
.await
.expect("install trigger");
}
#[tokio::test]
async fn a_trigger_cannot_move_a_ledgered_row_during_delete() {
let pool = boot_pool("lg_tenant_delete_trigger").await;
install_tenant_rewrite_trigger(
&pool,
"lg_move_on_delete",
"UPDATE OF deleted_at",
"NEW.deleted_at IS NOT NULL",
)
.await;
let repo = PgLgMovableInvoiceRepository::with_pool_untracked(pool);
with_tenant("tenant-a".to_string(), async {
let id = repo
.save(&NewLgMovableInvoice {
reference: "A-1".to_string(),
tenant_id: "tenant-a".to_string(),
})
.await
.expect("insert")
.id;
let err = repo
.delete_by_id(id)
.await
.expect_err("the delete moves the row");
assert_tenant_change(&err, id);
let live = repo
.find_by_id(id)
.await
.expect("read")
.expect("the delete rolled back");
assert_eq!(live.tenant_id, "tenant-a");
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 1);
})
.await;
}
#[tokio::test]
async fn a_trigger_cannot_move_a_ledgered_row_during_update() {
let pool = boot_pool("lg_tenant_update_trigger").await;
install_tenant_rewrite_trigger(
&pool,
"lg_move_on_update",
"UPDATE OF reference",
"NEW.reference = 'A-2'",
)
.await;
let repo = PgLgMovableInvoiceRepository::with_pool_untracked(pool);
with_tenant("tenant-a".to_string(), async {
let id = repo
.save(&NewLgMovableInvoice {
reference: "A-1".to_string(),
tenant_id: "tenant-a".to_string(),
})
.await
.expect("insert")
.id;
let err = repo
.update(
id,
&UpdateLgMovableInvoice {
reference: Patch::Set("A-2".to_string()),
..Default::default()
},
)
.await
.expect_err("the update moves the row");
assert_tenant_change(&err, id);
let live = repo
.find_by_id(id)
.await
.expect("read")
.expect("the update rolled back");
assert_eq!(
(live.reference.as_str(), live.tenant_id.as_str()),
("A-1", "tenant-a")
);
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 1);
})
.await;
}
#[tokio::test]
async fn a_trigger_cannot_move_a_ledgered_row_during_insert() {
let pool = boot_pool("lg_tenant_insert_trigger").await;
install_tenant_rewrite_trigger(&pool, "lg_move_on_insert", "INSERT", "1").await;
let repo = PgLgMovableInvoiceRepository::with_pool_untracked(pool);
let err = with_tenant("tenant-a".to_string(), async {
repo.save(&NewLgMovableInvoice {
reference: "A-1".to_string(),
tenant_id: "tenant-a".to_string(),
})
.await
.expect_err("the insert moves the row")
})
.await;
assert!(
matches!(
err.downcast_chain_ref::<autumn_web::ledger::LedgerError>(),
Some(autumn_web::ledger::LedgerError::TenantChange { .. })
),
"{err}"
);
assert!(
repo.across_tenants()
.find_all()
.await
.expect("list")
.is_empty(),
"the insert rolled back"
);
}
#[tokio::test]
async fn a_trigger_cannot_move_a_ledgered_row_during_restore() {
let pool = boot_pool("lg_tenant_restore_trigger").await;
let repo = PgLgMovableInvoiceRepository::with_pool_untracked(pool.clone());
with_tenant("tenant-a".to_string(), async {
let id = repo
.save(&NewLgMovableInvoice {
reference: "A-1".to_string(),
tenant_id: "tenant-a".to_string(),
})
.await
.expect("insert")
.id;
repo.delete_by_id(id).await.expect("delete");
install_tenant_rewrite_trigger(
&pool,
"lg_move_on_restore",
"UPDATE OF deleted_at",
"NEW.deleted_at IS NULL",
)
.await;
let err = repo
.restore(id)
.await
.expect_err("the restore moves the row");
assert_tenant_change(&err, id);
assert!(
repo.find_by_id(id).await.expect("read").is_none(),
"the restore rolled back"
);
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 2);
})
.await;
}
#[tokio::test]
async fn every_ledger_read_refuses_across_tenants() {
let pool = boot_pool("lg_tenant_across").await;
let repo = PgLgTenantInvoiceRepository::with_pool_untracked(pool);
let id = with_tenant("tenant-a".to_string(), async {
repo.save(&NewLgTenantInvoice {
reference: "A-1".to_string(),
})
.await
.expect("insert")
.id
})
.await;
let across = repo.across_tenants();
let refused = |result: Result<(), autumn_web::AutumnError>, read: &str| {
let err = result.expect_err(read);
assert!(
err.to_string()
.contains("cross-tenant ledger reads are not supported"),
"{read}: {err}"
);
};
refused(across.ledger_revisions(id).await.map(drop), "revisions");
refused(
across
.ledger_revisions_page(id, LedgerPageRequest::first(10))
.await
.map(drop),
"page",
);
refused(across.ledger_verify(id).await.map(drop), "verify");
refused(across.ledger_pin(id).await.map(drop), "pin");
refused(across.ledger_head(id).await.map(drop), "head");
refused(across.ledger_high_water(id).await.map(drop), "high water");
refused(
across
.ledger_as_of_at(id, LedgerAsOf::default())
.await
.map(drop),
"as of",
);
let now = Utc::now();
refused(across.ledger_diff(id, now, now).await.map(drop), "diff");
}
#[tokio::test]
async fn ledger_revisions_page_walks_the_chain_in_order() {
let pool = boot_pool("lg_page_walk").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let id = write_three_revisions(&repo).await;
for amount in [4, 5] {
repo.update(
id,
&UpdateLgInvoice {
amount_cents: Patch::Set(amount),
..Default::default()
},
)
.await
.expect("update");
}
let mut seen = Vec::new();
let mut request = LedgerPageRequest::first(2);
let mut pages = 0;
loop {
let page = repo.ledger_revisions_page(id, request).await.expect("page");
pages += 1;
assert!(page.revisions.len() <= 2);
seen.extend(page.revisions);
match page.next {
Some(cursor) => request = LedgerPageRequest::after(cursor, 2),
None => break,
}
}
assert_eq!(pages, 3);
assert_eq!(
seen.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5]
);
assert_eq!(seen, repo.ledger_revisions(id).await.expect("full read"));
}
#[tokio::test]
async fn ledger_revisions_page_clamps_a_zero_limit() {
let pool = boot_pool("lg_page_zero").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let id = write_three_revisions(&repo).await;
let page = repo
.ledger_revisions_page(id, LedgerPageRequest::first(0))
.await
.expect("page");
assert_eq!(page.revisions.len(), 1);
assert!(page.next.is_some());
}
#[tokio::test]
async fn a_duplicate_seq_on_a_page_edge_is_not_skipped() {
let pool = boot_pool("lg_page_dup").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"INSERT INTO _autumn_ledger_revisions \
(table_name, tenant_id, record_id, seq, op, actor, request_id, snapshot, \
valid_from, recorded_at, prev_hash, hash) \
SELECT table_name, 'forged', record_id, seq, op, actor, request_id, \
snapshot, valid_from, recorded_at, prev_hash, hash \
FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 2",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("insert a duplicate seq");
}
let mut seqs = Vec::new();
let mut request = LedgerPageRequest::first(2);
loop {
let page = repo.ledger_revisions_page(id, request).await.expect("page");
seqs.extend(page.revisions.iter().map(|r| r.seq));
match page.next {
Some(cursor) => request = LedgerPageRequest::after(cursor, 2),
None => break,
}
}
assert_eq!(seqs, vec![1, 2, 2, 3]);
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a duplicate seq must be reported");
assert_eq!(broken.kind, LedgerBreak::DuplicateSeq);
assert_eq!(broken.seq, 2);
}
#[tokio::test]
async fn a_forged_revision_at_the_minimum_cursor_is_not_skipped() {
let pool = boot_pool("lg_page_min").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"INSERT INTO _autumn_ledger_revisions \
(id, table_name, tenant_id, record_id, seq, op, actor, request_id, snapshot, \
valid_from, recorded_at, prev_hash, hash) \
SELECT -9223372036854775808, table_name, 'forged', record_id, \
-9223372036854775808, op, actor, request_id, snapshot, valid_from, \
recorded_at, prev_hash, hash \
FROM _autumn_ledger_revisions \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = 1",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("insert a forged revision at the minimum cursor");
}
let page = repo
.ledger_revisions_page(id, LedgerPageRequest::first(10))
.await
.expect("page");
assert_eq!(
page.revisions.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![i64::MIN, 1, 2, 3]
);
let report = repo.ledger_verify(id).await.expect("verify");
assert!(!report.is_intact(), "{report:?}");
assert_eq!(report.revisions_checked, 4);
}
#[tokio::test]
async fn a_delete_snapshots_the_row_a_trigger_rewrote() {
let pool = boot_pool("lg_delete_trigger").await;
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_invoices_touch AFTER UPDATE OF deleted_at ON lg_invoices \
WHEN NEW.deleted_at IS NOT NULL \
BEGIN UPDATE lg_invoices SET reference = reference || '-touched' \
WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let created = repo
.save(&NewLgInvoice {
reference: "INV-T".to_string(),
amount_cents: 1,
amount_rate: 1.0,
metadata: "{}".to_string(),
})
.await
.expect("insert");
repo.delete_by_id(created.id).await.expect("soft delete");
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(
report.is_intact(),
"no false positive after delete: {report:?}"
);
let then = repo
.ledger_as_of(created.id, now() + Duration::seconds(1))
.await
.expect("as-of")
.expect("state");
assert_eq!(then.reference, "INV-T-touched");
}
#[tokio::test]
async fn a_restore_snapshots_the_row_a_trigger_rewrote() {
let pool = boot_pool("lg_restore_trigger").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgInvoice {
reference: "INV-R".to_string(),
amount_cents: 1,
amount_rate: 1.0,
metadata: "{}".to_string(),
})
.await
.expect("insert");
repo.delete_by_id(created.id).await.expect("soft delete");
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_invoices_restore_touch AFTER UPDATE OF deleted_at ON lg_invoices \
WHEN NEW.deleted_at IS NULL \
BEGIN UPDATE lg_invoices SET reference = reference || '-restored' \
WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
repo.restore(created.id).await.expect("restore");
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(
report.is_intact(),
"no false positive after restore: {report:?}"
);
let head = repo
.ledger_revisions(created.id)
.await
.expect("revisions")
.pop()
.expect("head");
assert_eq!(head.snapshot["reference"], "INV-R-restored");
}
#[tokio::test]
async fn a_re_encoded_snapshot_is_refused() {
let pool = boot_pool("lg_reencoded").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let head = repo
.ledger_revisions(id)
.await
.expect("revisions")
.pop()
.expect("head");
let spaced = autumn_web::ledger::canonical_json(&head.snapshot).replacen(':', ": ", 1);
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ? \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(spaced)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(head.seq)
.execute(&mut *conn)
.await
.expect("re-encode");
}
let report = repo.ledger_verify(id).await.expect("verify reports it");
let broken = report.broken.expect("same meaning, different bytes");
assert_eq!(broken.kind, LedgerBreak::SnapshotNotCanonical);
assert_eq!(broken.seq, head.seq);
}
#[tokio::test]
async fn a_snapshot_the_model_cannot_decode_reports_schema_drift() {
let pool = boot_pool("lg_schema_drift").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let first = repo
.ledger_revisions(id)
.await
.expect("revisions")
.remove(0);
let mut old = first.snapshot.clone();
old.as_object_mut().expect("object").remove("amount_cents");
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ? \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(autumn_web::ledger::canonical_json(&old))
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(first.seq)
.execute(&mut *conn)
.await
.expect("simulate an older schema");
}
let err = repo
.ledger_as_of(id, first.recorded_at)
.await
.expect_err("cannot decode");
let text = err.to_string();
assert!(
text.contains("current model schema") && !text.contains("unreadable"),
"must not read as tamper evidence: {text}"
);
}
async fn rewrite_snapshot(
pool: &SqlitePool,
id: i64,
seq: i64,
edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>),
) {
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let mut snapshot = repo
.ledger_revisions(id)
.await
.expect("revisions")
.into_iter()
.find(|r| r.seq == seq)
.expect("revision")
.snapshot;
edit(snapshot.as_object_mut().expect("object"));
let mut conn = pool.get().await.expect("conn");
let changed = diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ? \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(autumn_web::ledger::canonical_json(&snapshot))
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(seq)
.execute(&mut *conn)
.await
.expect("simulate an older schema");
assert_eq!(changed, 1, "exactly one stored revision is rewritten");
}
#[tokio::test]
async fn a_revision_written_before_an_optional_field_existed_reconstructs() {
let pool = boot_pool("lg_schema_old_ok").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let first = repo
.ledger_revisions(id)
.await
.expect("revisions")
.remove(0);
rewrite_snapshot(&pool, id, first.seq, |m| {
assert!(m.remove("deleted_at").is_some(), "the key was present");
})
.await;
let then = repo
.ledger_as_of(id, first.recorded_at)
.await
.expect("old revision still reads")
.expect("state");
assert_eq!(then.amount_cents, 1, "revision 1, not a later one");
assert_eq!(then.deleted_at, None);
}
#[tokio::test]
async fn a_diff_across_a_schema_boundary_ignores_an_absent_option() {
let pool = boot_pool("lg_schema_diff").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
rewrite_snapshot(&pool, id, 1, |m| {
assert!(m.remove("deleted_at").is_some(), "the key was present");
})
.await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let (from, to) = (revisions[0].recorded_at, revisions[2].recorded_at);
let diff = repo
.ledger_diff(id, from, to)
.await
.expect("diff is defined");
assert_eq!((diff.from_seq, diff.to_seq), (Some(1), Some(3)));
let fields: Vec<&str> = diff.changes.iter().map(|c| c.column.as_str()).collect();
assert!(fields.contains(&"amount_cents"), "{fields:?}");
assert!(
!fields.contains(&"deleted_at"),
"an absent Option equals null, not a change: {fields:?}"
);
}
#[tokio::test]
async fn a_bulk_delete_snapshots_the_rows_a_trigger_rewrote() {
let pool = boot_pool("lg_bulk_delete_trigger").await;
{
let mut conn = pool.get().await.expect("conn");
conn.batch_execute(
"CREATE TRIGGER lg_invoices_bulk_touch AFTER UPDATE OF deleted_at ON lg_invoices \
WHEN NEW.deleted_at IS NOT NULL \
BEGIN UPDATE lg_invoices SET reference = reference || '-touched' \
WHERE id = NEW.id; END",
)
.await
.expect("install trigger");
}
let repo = PgLgInvoiceRepository::with_pool_untracked(pool);
let rows = ["A", "B"].map(|r| NewLgInvoice {
reference: r.to_string(),
amount_cents: 1,
amount_rate: 1.0,
metadata: "{}".to_string(),
});
let ids: Vec<i64> = repo
.save_many(&rows)
.await
.expect("insert")
.iter()
.map(|r| r.id)
.collect();
repo.delete_many(&ids).await.expect("bulk delete");
for id in ids {
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "record {id}: {report:?}");
let head = repo
.ledger_revisions(id)
.await
.expect("revisions")
.pop()
.expect("head");
let reference = head.snapshot["reference"].as_str().expect("reference");
assert!(reference.ends_with("-touched"), "{reference}");
assert!(!head.snapshot["deleted_at"].is_null());
}
}
#[tokio::test]
async fn as_of_refuses_an_encrypted_column_that_cannot_be_recovered() {
install_encryption_keys();
let pool = boot_pool("lg_vault_lost_key").await;
let repo = PgLgVaultNoteRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgVaultNote {
body: "public".to_string(),
secret: "hidden".to_string(),
})
.await
.expect("insert");
let first = repo
.ledger_revisions(created.id)
.await
.expect("revisions")
.remove(0);
{
let mut conn = pool.get().await.expect("conn");
let mut damaged = first.snapshot.clone();
damaged["secret"] = serde_json::json!("not-a-recoverable-envelope");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ? \
WHERE table_name = 'lg_vault_notes' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(autumn_web::ledger::canonical_json(&damaged))
.bind::<diesel::sql_types::BigInt, _>(created.id)
.bind::<diesel::sql_types::BigInt, _>(first.seq)
.execute(&mut *conn)
.await
.expect("damage the envelope");
}
let err = repo
.ledger_as_of(created.id, first.recorded_at)
.await
.expect_err("must not return ciphertext as plaintext");
let text = err.to_string();
assert!(text.contains("secret"), "names the column: {text}");
assert!(text.contains("revision 1"), "names the revision: {text}");
}
#[tokio::test]
async fn a_duplicate_key_snapshot_is_reported_as_not_canonical() {
let pool = boot_pool("lg_duplicate_key").await;
let repo = PgLgInvoiceRepository::with_pool_untracked(pool.clone());
let id = write_three_revisions(&repo).await;
let head = repo
.ledger_revisions(id)
.await
.expect("revisions")
.pop()
.expect("head");
let text = autumn_web::ledger::canonical_json(&head.snapshot);
let doubled = text.replacen('{', "{\"id\":0,", 1);
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ? \
WHERE table_name = 'lg_invoices' AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(doubled)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::BigInt, _>(head.seq)
.execute(&mut *conn)
.await
.expect("plant a duplicate key");
}
let report = repo.ledger_verify(id).await.expect("verify reports it");
let broken = report.broken.expect("duplicate key");
assert_eq!(broken.kind, LedgerBreak::SnapshotNotCanonical);
}
async fn age_chain(
pool: &SqlitePool,
table: &str,
revisions: Vec<autumn_web::ledger::LedgerRevision>,
mut edit: impl FnMut(i64, &mut serde_json::Map<String, serde_json::Value>),
) {
let record_id = revisions[0].record_id;
let mut previous: Option<String> = None;
for mut revision in revisions {
edit(
revision.seq,
revision.snapshot.as_object_mut().expect("object"),
);
revision.prev_hash.clone_from(&previous);
revision.hash = revision.compute_hash();
previous = Some(revision.hash.clone());
let mut conn = pool.get().await.expect("conn");
let changed = diesel::sql_query(
"UPDATE _autumn_ledger_revisions SET snapshot = ?, prev_hash = ?, hash = ? \
WHERE table_name = ? AND record_id = ? AND seq = ?",
)
.bind::<diesel::sql_types::Text, _>(autumn_web::ledger::canonical_json(&revision.snapshot))
.bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(revision.prev_hash)
.bind::<diesel::sql_types::Text, _>(revision.hash)
.bind::<diesel::sql_types::Text, _>(table)
.bind::<diesel::sql_types::BigInt, _>(record_id)
.bind::<diesel::sql_types::BigInt, _>(revision.seq)
.execute(&mut *conn)
.await
.expect("rewrite the revision");
assert_eq!(changed, 1, "exactly one stored revision is rewritten");
}
resync_high_water_mark(pool, table, record_id).await;
}
fn as_version_one(snapshot: &mut serde_json::Map<String, serde_json::Value>) {
snapshot.remove(autumn_web::ledger::SNAPSHOT_SCHEMA_KEY);
let title = snapshot.remove("title").expect("title");
snapshot.insert("subject".to_string(), title);
}
async fn write_note_chain(repo: &PgLgRenamedNoteRepository) -> i64 {
let created = repo
.save(&NewLgRenamedNote {
title: "a".to_string(),
})
.await
.expect("insert");
for title in ["b", "c"] {
tick().await;
repo.update(
created.id,
&UpdateLgRenamedNote {
title: Patch::Set(title.to_string()),
},
)
.await
.expect("update");
}
created.id
}
#[tokio::test]
async fn a_versioned_model_stamps_its_schema_version_into_each_revision() {
let pool = boot_pool("lg_schema_stamp").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(revisions.len(), 3);
for revision in &revisions {
assert_eq!(
revision.snapshot[autumn_web::ledger::SNAPSHOT_SCHEMA_KEY],
serde_json::json!(2),
"revision {}",
revision.seq
);
}
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
let plain = PgLgInvoiceRepository::with_pool_untracked(pool);
let plain_id = write_three_revisions(&plain).await;
let first = plain.ledger_revisions(plain_id).await.expect("revisions");
assert!(
first[0]
.snapshot
.get(autumn_web::ledger::SNAPSHOT_SCHEMA_KEY)
.is_none(),
"a version 1 model keeps its snapshots as they were"
);
}
#[tokio::test]
async fn a_revision_written_under_an_older_schema_is_converted_by_the_hook() {
let pool = boot_pool("lg_schema_upcast").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let first_at = revisions[0].recorded_at;
age_chain(&pool, "lg_renamed_notes", revisions, |seq, snapshot| {
if seq == 1 {
as_version_one(snapshot);
}
})
.await;
let then = repo
.ledger_as_of(id, first_at)
.await
.expect("an old revision still reads")
.expect("state");
assert_eq!(then.title, "a", "the renamed field carries its value");
let latest = repo
.ledger_as_of(id, now())
.await
.expect("a current revision reads")
.expect("state");
assert_eq!(latest.title, "c");
}
#[tokio::test]
async fn verify_does_not_accuse_a_record_whose_head_predates_a_schema_change() {
let pool = boot_pool("lg_schema_verify").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
age_chain(&pool, "lg_renamed_notes", revisions, |_, snapshot| {
as_version_one(snapshot);
})
.await;
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "a deploy is not tampering: {report:?}");
}
#[tokio::test]
async fn verify_ignores_a_column_added_after_the_head_was_written() {
let pool = boot_pool("lg_schema_new_column").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
age_chain(&pool, "lg_renamed_notes", revisions, |_, snapshot| {
as_version_one(snapshot);
assert!(snapshot.remove("deleted_at").is_some());
})
.await;
let report = repo.ledger_verify(id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
}
#[tokio::test]
async fn verify_is_strict_for_a_head_at_the_current_schema_version() {
let pool = boot_pool("lg_schema_strict").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
age_chain(&pool, "lg_renamed_notes", revisions, |_, snapshot| {
assert!(snapshot.remove("deleted_at").is_some());
})
.await;
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("a column the head lacks is a difference at the current version");
assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
}
#[tokio::test]
async fn delete_and_restore_revisions_carry_the_schema_version() {
let pool = boot_pool("lg_schema_delete_restore").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool);
let id = write_note_chain(&repo).await;
repo.delete_by_id(id).await.expect("soft delete");
repo.restore(id).await.expect("restore");
let revisions = repo.ledger_revisions(id).await.expect("revisions");
assert_eq!(
revisions.iter().map(|r| r.op).collect::<Vec<_>>(),
vec![
VersionOp::Insert,
VersionOp::Update,
VersionOp::Update,
VersionOp::Delete,
VersionOp::Update,
]
);
for revision in &revisions {
assert_eq!(
revision.snapshot[autumn_web::ledger::SNAPSHOT_SCHEMA_KEY],
serde_json::json!(2),
"revision {} ({:?})",
revision.seq,
revision.op
);
}
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn a_hook_can_rename_an_encrypted_column() {
let pool = boot_pool("lg_schema_sealed").await;
let repo = PgLgSealedNoteRepository::with_pool_untracked(pool.clone());
let created = repo
.save(&NewLgSealedNote {
body: "public".to_string(),
secret: "the secret".to_string(),
})
.await
.expect("insert");
let revisions = repo.ledger_revisions(created.id).await.expect("revisions");
let first_at = revisions[0].recorded_at;
age_chain(&pool, "lg_sealed_notes", revisions, |_, snapshot| {
snapshot.remove(autumn_web::ledger::SNAPSHOT_SCHEMA_KEY);
let envelope = snapshot.remove("secret").expect("secret");
snapshot.insert("hidden".to_string(), envelope);
})
.await;
let then = repo
.ledger_as_of(created.id, first_at)
.await
.expect("the renamed column still decrypts")
.expect("state");
assert_eq!(then.secret, "the secret");
let report = repo.ledger_verify(created.id).await.expect("verify");
assert!(report.is_intact(), "{report:?}");
}
#[tokio::test]
async fn verify_still_sees_a_changed_value_after_a_schema_change() {
let pool = boot_pool("lg_schema_still_sees").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
age_chain(&pool, "lg_renamed_notes", revisions, |_, snapshot| {
as_version_one(snapshot);
})
.await;
{
let mut conn = pool.get().await.expect("conn");
diesel::sql_query("UPDATE lg_renamed_notes SET title = 'edited' WHERE id = ?")
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(&mut *conn)
.await
.expect("edit the live row out of band");
}
let broken = repo
.ledger_verify(id)
.await
.expect("verify")
.broken
.expect("an out-of-band edit is still found");
assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
}
#[tokio::test]
async fn a_hook_that_refuses_a_shape_is_a_schema_error_and_verify_stays_quiet() {
let pool = boot_pool("lg_schema_hook_refuses").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let first_at = revisions[0].recorded_at;
age_chain(&pool, "lg_renamed_notes", revisions, |seq, snapshot| {
as_version_one(snapshot);
if seq == 1 {
snapshot.insert("legacy_flag".to_string(), serde_json::json!(true));
}
})
.await;
let err = repo
.ledger_as_of(id, first_at)
.await
.expect_err("the hook refuses revision 1");
let text = err.to_string();
assert!(text.contains("a legacy shape with no rule"), "{text}");
assert!(text.contains("current model schema"), "{text}");
assert!(
!text.contains("unreadable"),
"schema trouble is not tamper evidence: {text}"
);
assert!(repo.ledger_verify(id).await.expect("verify").is_intact());
}
#[tokio::test]
async fn a_revision_from_a_newer_model_reports_both_versions() {
let pool = boot_pool("lg_schema_newer").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let first_at = revisions[0].recorded_at;
age_chain(&pool, "lg_renamed_notes", revisions, |seq, snapshot| {
if seq == 1 {
snapshot.insert(
autumn_web::ledger::SNAPSHOT_SCHEMA_KEY.to_string(),
serde_json::json!(9),
);
let title = snapshot.remove("title").expect("title");
snapshot.insert("heading".to_string(), title);
}
})
.await;
let text = repo
.ledger_as_of(id, first_at)
.await
.expect_err("the model cannot read version 9")
.to_string();
assert!(text.contains("schema version 9"), "{text}");
assert!(text.contains("model is at 2"), "{text}");
assert!(text.contains("newer"), "{text}");
}
#[tokio::test]
async fn a_diff_across_a_schema_boundary_compares_both_sides_in_the_current_shape() {
let pool = boot_pool("lg_schema_boundary_diff").await;
let repo = PgLgRenamedNoteRepository::with_pool_untracked(pool.clone());
let id = write_note_chain(&repo).await;
let revisions = repo.ledger_revisions(id).await.expect("revisions");
let (from, to) = (revisions[0].recorded_at, revisions[2].recorded_at);
age_chain(&pool, "lg_renamed_notes", revisions, |seq, snapshot| {
if seq == 1 {
as_version_one(snapshot);
}
})
.await;
let diff = repo
.ledger_diff(id, from, to)
.await
.expect("diff is defined");
assert_eq!((diff.from_seq, diff.to_seq), (Some(1), Some(3)));
let fields: Vec<&str> = diff.changes.iter().map(|c| c.column.as_str()).collect();
assert_eq!(fields, vec!["title"], "only the real change: {fields:?}");
}