P3: the books' format moves only with the owner's go-ahead (D-39)
Books an update could write a newer format for open on their old format and are asked at every open; anything needing a new feature is refused until the owner agrees with the passphrase, after a verified backup that includes the keys. New books start on the current format. Each projection schema has its own database file, so an older version still opens the books until the owner agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
62 files changed
+5309
-628
No files matched your search
@@ -966,6 +966,90 @@ pub fn drill(
|
||||
checked
|
||||
}
|
||||
|
||||
/// Prove the book's own archive holds the book in `book_dir` exactly as it
|
||||
/// is now, its journal's head `head` (INVARIANT 3, and more): the archive
|
||||
/// cloned into `scratch` (a new folder, removed after) and proven whole as
|
||||
/// a restore proves a backup, its head `head`, its journal the book's byte
|
||||
/// for byte, and its TIN vault and keys the book's, file for file and byte
|
||||
/// for byte. What the format upgrade asks of the backup it makes first
|
||||
/// (plan/served.md 3.9: "a verified local backup that includes the
|
||||
/// keys"). The caller holds the store's lock, so the book can't move
|
||||
/// while it is compared.
|
||||
pub fn prove_archive(
|
||||
git: &Git,
|
||||
book_dir: &Path,
|
||||
head: &(u64, String),
|
||||
scratch: &Path,
|
||||
) -> Result<Proven, BackupError> {
|
||||
let archive = book_dir.join(ARCHIVE_DIR);
|
||||
let source = archive.display().to_string();
|
||||
let proven = verify(git, &source, scratch).and_then(|v| {
|
||||
if v.head != *head {
|
||||
return Err(BackupError::Damaged(format!(
|
||||
"it holds the books through event {}, and they are at event {}",
|
||||
v.head.0, head.0
|
||||
)));
|
||||
}
|
||||
if std::fs::read(book_dir.join(crate::JOURNAL_FILE))?
|
||||
!= std::fs::read(v.tree.join(crate::JOURNAL_FILE))?
|
||||
{
|
||||
return Err(BackupError::Damaged(
|
||||
"its journal isn't the books' own, byte for byte".into(),
|
||||
));
|
||||
}
|
||||
for kept in [crate::root::VAULT_DIR, crate::root::KEYS_DIR] {
|
||||
if held_files(book_dir, kept)? != held_files(&v.tree, kept)? {
|
||||
return Err(BackupError::Damaged(format!(
|
||||
"its {} isn't the books' own, file for file",
|
||||
if kept == crate::root::KEYS_DIR {
|
||||
"keys folder"
|
||||
} else {
|
||||
"TIN vault"
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
let git_dir = format!("--git-dir={}", v.tree.join(".git").display());
|
||||
let commit = git.run("rev-parse", &[os(&git_dir), os("rev-parse"), os("HEAD")])?;
|
||||
Ok(Proven {
|
||||
head: v.head,
|
||||
blobs: v.blobs,
|
||||
commit,
|
||||
})
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(scratch);
|
||||
proven
|
||||
}
|
||||
|
||||
/// The book's archive proven to hold the book as it is ([`prove_archive`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Proven {
|
||||
/// The journal's head it holds: the book's.
|
||||
pub head: (u64, String),
|
||||
/// Stored files re-hashed.
|
||||
pub blobs: u64,
|
||||
/// The commit proven: the archive's latest.
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
/// Every file under `rel` in `base`, by its path there, with its bytes
|
||||
/// (none when `rel` isn't there).
|
||||
fn held_files(base: &Path, rel: &str) -> Result<Vec<(String, Vec<u8>)>, BackupError> {
|
||||
if std::fs::symlink_metadata(base.join(rel)).is_err() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
files_under(base, Path::new(rel), &mut names)?;
|
||||
names.sort();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
let bytes = std::fs::read(base.join(&n))?;
|
||||
Ok((n, bytes))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the journal at `backup` is a byte prefix of the one at `live`
|
||||
/// (a drill: the backup holds the book's history, nothing else).
|
||||
pub fn is_prefix(live: &Path, backup: &Path) -> std::io::Result<bool> {
|
||||
@@ -1471,6 +1555,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The backup a format upgrade makes first (plan 3.9): the archive
|
||||
/// proves to hold the book exactly as it is, its keys and vault file
|
||||
/// for file; a key, a vault record or the journal that differs from
|
||||
/// the archive's latest, or a head it doesn't hold, is refused; and
|
||||
/// the scratch folder goes either way.
|
||||
#[test]
|
||||
fn the_archive_proves_to_hold_the_book_as_it_is_keys_and_all() {
|
||||
let git = Git::find().expect("git is installed");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = dir.path().join("book");
|
||||
let mut s = book(&b);
|
||||
let keys = b.join(crate::root::KEYS_DIR);
|
||||
std::fs::create_dir(&keys).unwrap();
|
||||
std::fs::write(keys.join(crate::root::LEGACY_KEY_FILE), b"ab".repeat(32)).unwrap();
|
||||
let vault = b.join(crate::root::VAULT_DIR);
|
||||
std::fs::create_dir(&vault).unwrap();
|
||||
std::fs::write(vault.join("tin-1.bin"), b"sealed").unwrap();
|
||||
let made = commit(&git, &b, "acme", s.head()).unwrap().unwrap();
|
||||
let scratch = dir.path().join("check");
|
||||
let proven = prove_archive(&git, &b, &s.head(), &scratch).unwrap();
|
||||
assert_eq!(proven.head, s.head());
|
||||
assert_eq!(proven.commit, made.commit);
|
||||
assert_eq!(proven.blobs, 1);
|
||||
assert!(!scratch.exists(), "the scratch folder is removed");
|
||||
let refused = |b: &Path, head: &(u64, String)| {
|
||||
let e = prove_archive(&git, b, head, &scratch).unwrap_err();
|
||||
assert!(!scratch.exists());
|
||||
e.to_string()
|
||||
};
|
||||
// A key that isn't the one backed up.
|
||||
std::fs::write(keys.join(crate::root::LEGACY_KEY_FILE), b"cd".repeat(32)).unwrap();
|
||||
assert!(refused(&b, &s.head()).contains("keys folder"));
|
||||
std::fs::write(keys.join(crate::root::LEGACY_KEY_FILE), b"ab".repeat(32)).unwrap();
|
||||
// A key the backup lacks.
|
||||
std::fs::write(keys.join("other.key"), b"x").unwrap();
|
||||
assert!(refused(&b, &s.head()).contains("keys folder"));
|
||||
std::fs::remove_file(keys.join("other.key")).unwrap();
|
||||
// A vault record that isn't the one backed up.
|
||||
std::fs::write(vault.join("tin-1.bin"), b"sealed again").unwrap();
|
||||
assert!(refused(&b, &s.head()).contains("TIN vault"));
|
||||
std::fs::write(vault.join("tin-1.bin"), b"sealed").unwrap();
|
||||
assert!(prove_archive(&git, &b, &s.head(), &scratch).is_ok());
|
||||
// The books moved on since the backup.
|
||||
s.add_account("6000", "Fees", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = refused(&b, &s.head());
|
||||
assert!(e.contains("they are at event"), "{e}");
|
||||
// The head it holds, but the journal on disk changed under it.
|
||||
let old_head = made.head.clone();
|
||||
let e = refused(&b, &old_head);
|
||||
assert!(e.contains("byte for byte"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backup_holding_a_link_or_a_stranger_is_refused_before_checkout() {
|
||||
let git = Git::find().expect("git is installed");
|
||||
|
||||
@@ -82,6 +82,18 @@ pub enum StoreError {
|
||||
#[error("{0}")]
|
||||
FormatRefused(String),
|
||||
|
||||
/// A write that needs a journal format or feature these books haven't
|
||||
/// declared: they were written by an older version, and wait for the
|
||||
/// owner's go-ahead to move to this version's format (the format
|
||||
/// upgrade: plan/served.md 3.9, D-39). Nothing was written; everything
|
||||
/// that needs nothing new still works.
|
||||
#[error("{}", format_upgrade_text(need))]
|
||||
FormatUpgradePending {
|
||||
/// What the write needs: "journal format 2", "the journal
|
||||
/// feature x".
|
||||
need: String,
|
||||
},
|
||||
|
||||
/// Replay produced different ids than the journal recorded.
|
||||
#[error("replay mismatch: {0}")]
|
||||
ReplayMismatch(String),
|
||||
@@ -137,6 +149,24 @@ pub enum StoreError {
|
||||
CurrencyMismatch(String, String),
|
||||
}
|
||||
|
||||
/// The guard code a refusal for books waiting for their format upgrade
|
||||
/// carries (plan/served.md 3.9: `format_upgrade_pending`).
|
||||
pub const FORMAT_UPGRADE_PENDING: &str = "format_upgrade_pending";
|
||||
|
||||
/// What [`StoreError::FormatUpgradePending`] says: what the write needs,
|
||||
/// that nothing was changed, that only the owner can give the go-ahead
|
||||
/// (and where), and that the rest still works.
|
||||
fn format_upgrade_text(need: &str) -> String {
|
||||
format!(
|
||||
"{FORMAT_UPGRADE_PENDING}: this needs {need}, and these books are still in the journal \
|
||||
format an older version of wanderbooks wrote: they are waiting for the owner's \
|
||||
go-ahead to upgrade to this version's format. Nothing was changed. Only the owner can \
|
||||
give it, with the owner passphrase: in the app (it asks when it opens the books) or \
|
||||
with `wanderbooks books upgrade ID` at a terminal; no tool can. Until then everything \
|
||||
that needs nothing new works as before"
|
||||
)
|
||||
}
|
||||
|
||||
/// What [`StoreError::NewerJournal`] says. Only a line whose own hash was
|
||||
/// checked lets it say that line is intact; it never vouches for the
|
||||
/// lines after it, which this version can't read.
|
||||
|
||||
@@ -20,10 +20,16 @@
|
||||
//! can't read, and say whether that line is intact.
|
||||
//!
|
||||
//! A line an earlier version can't read is announced by a feature
|
||||
//! ([`KNOWN_FEATURES`]): the writer declares it (`journal.format_set`,
|
||||
//! the features in force and the new one) just before the first line that
|
||||
//! needs it, so the earlier reader stops at the declaration, and books
|
||||
//! that never use the feature stay readable by it.
|
||||
//! ([`KNOWN_FEATURES`]): the books declare it (`journal.format_set`)
|
||||
//! before the first line that needs it, so the earlier reader stops at the
|
||||
//! declaration.
|
||||
//!
|
||||
//! The other way round, books an older version wrote are in an older
|
||||
//! format: this version reads them as they are, and writes nothing that
|
||||
//! needs a format or feature they haven't declared until the owner says
|
||||
//! go (the format upgrade, rules.md B-27, plan/served.md 3.9, D-39:
|
||||
//! [`JournalFormat::upgrade`], [`current`]). New books declare this
|
||||
//! version's whole format as they are created.
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{JournalLine, ReadLine};
|
||||
@@ -35,11 +41,11 @@ pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
||||
/// Optional journal features this version reads. Each marks lines an
|
||||
/// older version can't read: a new event type (which it would refuse as
|
||||
/// unknown), or a field or value on an event type it knows (which it
|
||||
/// would misread silently). A writer declares the feature
|
||||
/// (`journal.format_set`) before its first such line
|
||||
/// ([`crate::Store`]'s `ensure_feature`), so the older version stops
|
||||
/// there instead (rules.md B-27). Books that never use one stay readable
|
||||
/// by versions before it.
|
||||
/// would misread silently). The books declare it (`journal.format_set`)
|
||||
/// before their first such line, so the older version stops there instead
|
||||
/// (rules.md B-27). Every one is declared at once: by new books as they
|
||||
/// are created, and by older books at their format upgrade ([`current`]);
|
||||
/// until then a write that needs one is refused.
|
||||
pub const KNOWN_FEATURES: &[&str] = &[
|
||||
MEMBERS_CUMULATIVE,
|
||||
FEATURE_ACCOUNT_RENAMED,
|
||||
@@ -59,16 +65,15 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
/// rule, which can split it differently.
|
||||
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
|
||||
|
||||
/// The feature declared before the first `account.renamed` (P3, defect
|
||||
/// 15).
|
||||
/// Feature: `account.renamed`, a new event type (P3, defect 15).
|
||||
pub const FEATURE_ACCOUNT_RENAMED: &str = "account_renamed";
|
||||
|
||||
/// The feature declared before the first `settings.set` naming
|
||||
/// `policy.require_tax_category` (P3, defect 8).
|
||||
/// Feature: a `settings.set` naming `policy.require_tax_category`, a key
|
||||
/// an earlier version doesn't know (P3, defect 8).
|
||||
pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category";
|
||||
|
||||
/// The feature a `settings.set` naming `key` needs declared first: a key
|
||||
/// an earlier version doesn't know.
|
||||
/// The feature a `settings.set` naming `key` needs the books to have
|
||||
/// declared: a key an earlier version doesn't know.
|
||||
pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
||||
match key {
|
||||
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
|
||||
@@ -262,6 +267,122 @@ impl JournalFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// The journal format this version writes: the newest format it reads,
|
||||
/// with every feature it knows, sorted. New books declare it as they are
|
||||
/// created ([`crate::Store::create`]), and books in an older format move
|
||||
/// to it in one step at their format upgrade
|
||||
/// ([`crate::Store::upgrade_format`]).
|
||||
pub fn current() -> JournalFormat {
|
||||
let mut features: Vec<String> = KNOWN_FEATURES.iter().map(|f| f.to_string()).collect();
|
||||
features.sort_unstable();
|
||||
features.dedup();
|
||||
JournalFormat {
|
||||
format: SUPPORTED_JOURNAL_FORMAT,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
/// A format upgrade books wait for: from the format they are in to this
|
||||
/// version's ([`current`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FormatUpgrade {
|
||||
/// The format the books are in.
|
||||
pub from: JournalFormat,
|
||||
/// The format the upgrade declares: this version's.
|
||||
pub to: JournalFormat,
|
||||
}
|
||||
|
||||
impl FormatUpgrade {
|
||||
/// What the upgrade adds, in words: a newer format number, then each
|
||||
/// feature not yet declared.
|
||||
pub fn adds(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if self.to.format > self.from.format {
|
||||
out.push(format!("journal format {}", self.to.format));
|
||||
}
|
||||
out.extend(
|
||||
self.to
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| !self.from.has_feature(f))
|
||||
.map(|f| format!("the journal feature {f}")),
|
||||
);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl JournalFormat {
|
||||
/// Whether the books declared the feature `feature`.
|
||||
pub fn has_feature(&self, feature: &str) -> bool {
|
||||
self.features.iter().any(|f| f == feature)
|
||||
}
|
||||
|
||||
/// Refuse a write that needs journal format `format` (or a later one)
|
||||
/// in books that haven't declared it: they wait for their format
|
||||
/// upgrade (FormatUpgradePending; [`crate::Store`]'s writes ask this
|
||||
/// before they journal anything).
|
||||
pub fn require_format(&self, format: u32) -> Result<(), StoreError> {
|
||||
if self.format >= format {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StoreError::FormatUpgradePending {
|
||||
need: format!("journal format {format}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a write that needs the feature `feature` in books that
|
||||
/// haven't declared it ([`Self::require_format`]'s kin).
|
||||
pub fn require_feature(&self, feature: &str) -> Result<(), StoreError> {
|
||||
if self.has_feature(feature) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StoreError::FormatUpgradePending {
|
||||
need: format!("the journal feature {feature}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The format upgrade books in this format wait for, if any (rules.md
|
||||
/// B-27, plan/served.md 3.9, D-39).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: books an older version wrote are never moved to a newer
|
||||
/// format, which that version can't open, without the owner's
|
||||
/// go-ahead, and whether they wait for one is a fact of their
|
||||
/// journal alone.
|
||||
/// SOURCE: plan/served.md 3.9 ("Format changes are the server's gate,
|
||||
/// not the updater's": a server that would write a newer
|
||||
/// journal format than a book has opens it on its old format
|
||||
/// until the owner consents); D-39 (asked at every open until
|
||||
/// given).
|
||||
/// INVARIANTS:
|
||||
/// 1. Some exactly when this version writes a higher format number,
|
||||
/// or knows a feature the books haven't declared.
|
||||
/// 2. Its `to` is this version's format (current()): every feature
|
||||
/// it knows, so a book's format moves exactly once per version.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the books declared nothing (format 1, written before the
|
||||
/// format gate): SHOULD wait for the upgrade to format 2.
|
||||
/// 2. IF they declared this version's format and every feature it
|
||||
/// knows: SHOULD wait for none.
|
||||
/// 3. IF they declared this format without a feature this version
|
||||
/// knows: SHOULD wait for one that adds it.
|
||||
/// ```
|
||||
pub fn upgrade(&self) -> Option<FormatUpgrade> {
|
||||
self.upgrade_to(current())
|
||||
}
|
||||
|
||||
/// [`Self::upgrade`] for a version whose format is `to`.
|
||||
fn upgrade_to(&self, to: JournalFormat) -> Option<FormatUpgrade> {
|
||||
let behind = self.format < to.format || to.features.iter().any(|f| !self.has_feature(f));
|
||||
behind.then(|| FormatUpgrade {
|
||||
from: self.clone(),
|
||||
to,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `journal.format_set` line, refusing a format or feature this
|
||||
/// version doesn't read.
|
||||
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
|
||||
@@ -570,6 +691,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// This version's format: the newest it reads, every feature it
|
||||
/// knows, each once, sorted (what new books and an upgrade declare).
|
||||
#[test]
|
||||
fn this_versions_format_is_the_newest_with_every_known_feature() {
|
||||
let c = current();
|
||||
assert_eq!(c.format, SUPPORTED_JOURNAL_FORMAT);
|
||||
let mut known: Vec<String> = KNOWN_FEATURES.iter().map(|f| f.to_string()).collect();
|
||||
known.sort();
|
||||
known.dedup();
|
||||
assert_eq!(c.features, known);
|
||||
// What it declares reads back as itself.
|
||||
let features: Vec<&str> = c.features.iter().map(String::as_str).collect();
|
||||
let payload = JournalFormat::default()
|
||||
.declaration(c.format, &features)
|
||||
.unwrap();
|
||||
assert_eq!(declared(&line(FORMAT_SET, payload)).unwrap(), c);
|
||||
assert_eq!(c.upgrade(), None, "this version's books wait for nothing");
|
||||
}
|
||||
|
||||
/// Books wait for an upgrade exactly when this version writes a higher
|
||||
/// format number or knows a feature they haven't declared; the upgrade
|
||||
/// is always to this version's whole format (SCENARIOS 1 to 3).
|
||||
#[test]
|
||||
fn books_wait_for_an_upgrade_exactly_when_behind() {
|
||||
let at = |format: u32, features: &[&str]| JournalFormat {
|
||||
format,
|
||||
features: features.iter().map(|f| f.to_string()).collect(),
|
||||
};
|
||||
// Format 1 (nothing declared): waits for this version's format.
|
||||
let old = JournalFormat::default();
|
||||
let up = old.upgrade().expect("format 1 books wait for format 2");
|
||||
assert_eq!((up.from.clone(), up.to.clone()), (old, current()));
|
||||
assert_eq!(
|
||||
up.adds()[0],
|
||||
format!("journal format {SUPPORTED_JOURNAL_FORMAT}")
|
||||
);
|
||||
// A version that knows features: each one missing makes books wait,
|
||||
// and the upgrade adds all of them at once.
|
||||
let to = at(2, &["a", "b"]);
|
||||
for (books, adds) in [
|
||||
(
|
||||
at(1, &[]),
|
||||
vec![
|
||||
"journal format 2",
|
||||
"the journal feature a",
|
||||
"the journal feature b",
|
||||
],
|
||||
),
|
||||
(
|
||||
at(2, &[]),
|
||||
vec!["the journal feature a", "the journal feature b"],
|
||||
),
|
||||
(at(2, &["b"]), vec!["the journal feature a"]),
|
||||
(at(2, &["a"]), vec!["the journal feature b"]),
|
||||
] {
|
||||
let up = books.upgrade_to(to.clone()).expect("behind");
|
||||
assert_eq!(up.to, to, "the whole format, at once");
|
||||
assert_eq!(up.adds(), adds, "{books:?}");
|
||||
}
|
||||
assert_eq!(at(2, &["a", "b"]).upgrade_to(to.clone()), None);
|
||||
assert_eq!(
|
||||
at(2, &["b", "a"]).upgrade_to(to.clone()),
|
||||
None,
|
||||
"in any order"
|
||||
);
|
||||
// Nothing ahead of this version counts as behind (it never opens).
|
||||
assert_eq!(at(3, &["a", "b"]).upgrade_to(to), None);
|
||||
}
|
||||
|
||||
/// A write that needs a format or feature the books haven't declared
|
||||
/// is refused as waiting for the upgrade, saying what it needs; one
|
||||
/// the books declared passes.
|
||||
#[test]
|
||||
fn a_write_needs_what_the_books_declared() {
|
||||
let at = |format: u32, features: &[&str]| JournalFormat {
|
||||
format,
|
||||
features: features.iter().map(|f| f.to_string()).collect(),
|
||||
};
|
||||
let need = |r: Result<(), StoreError>| match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => need,
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
assert_eq!(need(at(1, &[]).require_format(2)), "journal format 2");
|
||||
at(2, &[]).require_format(2).unwrap();
|
||||
at(2, &[]).require_format(1).unwrap();
|
||||
assert_eq!(
|
||||
need(at(2, &["a"]).require_feature("b")),
|
||||
"the journal feature b"
|
||||
);
|
||||
at(2, &["a", "b"]).require_feature("b").unwrap();
|
||||
// The refusal names the guard, says nothing changed, and that only
|
||||
// the owner can agree, and how.
|
||||
let text = StoreError::FormatUpgradePending {
|
||||
need: "journal format 2".into(),
|
||||
}
|
||||
.to_string();
|
||||
for says in [
|
||||
"format_upgrade_pending:",
|
||||
"needs journal format 2",
|
||||
"Nothing was changed",
|
||||
"Only the owner can give it",
|
||||
"owner passphrase",
|
||||
"wanderbooks books upgrade",
|
||||
"no tool can",
|
||||
] {
|
||||
assert!(text.contains(says), "{says}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An upgrade never weakens the gate: a version that knows fewer
|
||||
/// features than the books declared still stops at the declaration,
|
||||
/// as newer, and one that knows them reads it.
|
||||
#[test]
|
||||
fn a_reader_that_doesnt_know_a_declared_feature_still_refuses_it_as_newer() {
|
||||
let l = line(FORMAT_SET, json!({"format": 2, "features": ["a"]}));
|
||||
match declared_knowing(&l, &[]) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features a")
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
declared_knowing(&l, &["a"]).unwrap(),
|
||||
JournalFormat {
|
||||
format: 2,
|
||||
features: vec!["a".into()]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A format newer than this version reads is newer whatever shape the
|
||||
/// rest of its declaration takes, and however large its number: a
|
||||
/// newer format may define its features differently, so they are
|
||||
|
||||
@@ -43,11 +43,11 @@ pub mod uploads;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
pub use format::{JournalFormat, SUPPORTED_JOURNAL_FORMAT};
|
||||
pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT};
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::{
|
||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||
Attachment, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, LEGACY_DB_FILE,
|
||||
LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, Statement, Store, Subject,
|
||||
VerifyReport,
|
||||
VerifyReport, database_in_use, db_file, db_file_for, is_database_file,
|
||||
};
|
||||
@@ -665,7 +665,9 @@ impl Root {
|
||||
/// built in a staging folder: every stored file re-hashed against its
|
||||
/// name, the copy opened (its database rebuilt from the journal) with
|
||||
/// the source's head, every file it names present, verified end to
|
||||
/// end, given its settings if the source keeps none (and verified
|
||||
/// end, moved to this version's journal format if the source's is
|
||||
/// older (the copy is new books of this version; the source keeps its
|
||||
/// own), given its settings if the source keeps none (and verified
|
||||
/// again), synced, and only then renamed into place.
|
||||
pub fn adopt(
|
||||
&self,
|
||||
@@ -781,15 +783,22 @@ impl Root {
|
||||
}
|
||||
store.verify_and_heal()?;
|
||||
let blobs_verified = store.verify_blobs()?;
|
||||
// The copy is new books of this version, in its journal format
|
||||
// (the original, left as it was, keeps its own): it waits for
|
||||
// no format upgrade, and its settings can move in.
|
||||
let format_upgraded = store.upgrade_format()?.is_some();
|
||||
let (settings_imported, settings_ignored) = match (&options.settings, keeps_settings) {
|
||||
(Some((changes, source)), false) => {
|
||||
let n = store.adopt_settings(changes, source)?.len();
|
||||
store.verify_and_heal()?;
|
||||
(n, false)
|
||||
}
|
||||
(Some(_), true) => (0, true),
|
||||
(None, _) => (0, false),
|
||||
};
|
||||
// Verified again for what was written on the way in.
|
||||
if store.head() != head {
|
||||
store.verify_and_heal()?;
|
||||
}
|
||||
let final_head = store.head();
|
||||
drop(store);
|
||||
sync_dir(&staging)?;
|
||||
@@ -806,6 +815,7 @@ impl Root {
|
||||
settings_imported,
|
||||
settings_ignored,
|
||||
source_lock_created,
|
||||
format_upgraded,
|
||||
})
|
||||
})();
|
||||
let renamed = built.and_then(|report| {
|
||||
@@ -1660,9 +1670,9 @@ pub struct AdoptReport {
|
||||
pub id: BookId,
|
||||
/// The source's head, `(seq, hash)`, verified before copying.
|
||||
pub source_head: (u64, String),
|
||||
/// The adopted books' head: the source's, plus (when settings were
|
||||
/// moved in) their `settings.set`, after the journal format they need
|
||||
/// if the source's is older.
|
||||
/// The adopted books' head: the source's, plus this version's journal
|
||||
/// format if the source's is older (`format_upgraded`), plus (when
|
||||
/// settings were moved in) their `settings.set`.
|
||||
pub head: (u64, String),
|
||||
/// Stored files copied, each re-hashed against its name.
|
||||
pub blob_files: u64,
|
||||
@@ -1686,6 +1696,9 @@ pub struct AdoptReport {
|
||||
pub settings_ignored: bool,
|
||||
/// The source had no lock file, so taking its lock made one.
|
||||
pub source_lock_created: bool,
|
||||
/// The copy was moved to this version's journal format (the source's
|
||||
/// was older; the source keeps its own).
|
||||
pub format_upgraded: bool,
|
||||
}
|
||||
|
||||
/// The source's writer lock: its lock file opened read-only (so a
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::error::StoreError;
|
||||
use crate::format::JournalFormat;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links,
|
||||
query_attachments, query_reconciliations, query_statements, read_attachment_bytes,
|
||||
replay_line_into_ledger,
|
||||
@@ -292,7 +292,8 @@ impl Snapshot {
|
||||
/// by the concurrency test, 2026-10-13, as a false "differs").
|
||||
pub fn check_disk(&self) -> DiskCheck {
|
||||
use rusqlite::OpenFlags;
|
||||
let path = self.data_dir.join(DB_FILE);
|
||||
// The file this version's writer keeps (store::database_in_use).
|
||||
let path = crate::store::database_in_use(&self.data_dir);
|
||||
if !path.exists() {
|
||||
return DiskCheck::Missing;
|
||||
}
|
||||
@@ -374,7 +375,7 @@ impl Snapshot {
|
||||
|
||||
/// `file:` URI for `path`, read-only and immutable (sqlite.org/uri.html):
|
||||
/// every byte outside the unreserved set is percent-encoded.
|
||||
fn immutable_uri(path: &Path) -> String {
|
||||
pub(crate) fn immutable_uri(path: &Path) -> String {
|
||||
let mut out = String::from("file:");
|
||||
for b in path.to_string_lossy().bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"/-_.~".contains(&b) {
|
||||
|
||||
@@ -52,8 +52,101 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
/// File names inside the data directory.
|
||||
pub const JOURNAL_FILE: &str = "journal.jsonl";
|
||||
/// File names inside the data directory.
|
||||
pub const DB_FILE: &str = "wanderbooks.db";
|
||||
|
||||
/// The projection database's one file before each schema had a file of
|
||||
/// its own (every version through P2, whose schema was 12). This version
|
||||
/// uses it only when it already holds this version's schema, so books that
|
||||
/// need no migration aren't rebuilt for nothing; otherwise it is left as
|
||||
/// it is, byte for byte, for the version that wrote it ([`db_file_for`]).
|
||||
pub const LEGACY_DB_FILE: &str = "wanderbooks.db";
|
||||
|
||||
/// The projection database's file for schema `schema`:
|
||||
/// `wanderbooks.v{schema}.db`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: going back to an older version always finds the books it can
|
||||
/// open (plan/served.md 3.9: before consent, the way back is always
|
||||
/// there), however far this version's projection has moved.
|
||||
/// SOURCE: plan/served.md 3.9, the lead's decision (2026-10-21: each
|
||||
/// schema in a file of its own); rules.md B-27.
|
||||
/// INVARIANTS:
|
||||
/// 1. A version opens or creates only its own schema's file (or the
|
||||
/// legacy file when that already holds its schema); a missing one is
|
||||
/// built from the journal.
|
||||
/// 2. It never migrates, writes or deletes a file of another schema,
|
||||
/// older or newer: each is left byte for byte for its own version.
|
||||
/// 3. The name depends only on the schema number, so versions agree on
|
||||
/// it whatever number each was given.
|
||||
/// ```
|
||||
pub fn db_file_for(schema: u32) -> String {
|
||||
format!("wanderbooks.v{schema}.db")
|
||||
}
|
||||
|
||||
/// This version's own projection file: [`db_file_for`] its schema
|
||||
/// ([`crate::schema::SCHEMA_VERSION`]).
|
||||
pub fn db_file() -> String {
|
||||
db_file_for(crate::schema::SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
/// Whether `name` is a projection database's file, of any schema (the
|
||||
/// legacy one included): what [`Store::other_databases`] lists.
|
||||
pub fn is_database_file(name: &str) -> bool {
|
||||
name == LEGACY_DB_FILE
|
||||
|| name
|
||||
.strip_prefix("wanderbooks.v")
|
||||
.and_then(|rest| rest.strip_suffix(".db"))
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The projection database this version uses for the books in `data_dir`
|
||||
/// ([`db_file_for`] INVARIANT 1): its own schema's file if there is one;
|
||||
/// else the legacy file if that already holds this version's schema; else
|
||||
/// its own schema's file, to be built from the journal. Read only: a file
|
||||
/// of another schema is looked at, never opened for writing.
|
||||
pub fn database_in_use(data_dir: &Path) -> PathBuf {
|
||||
let own = data_dir.join(db_file());
|
||||
if std::fs::symlink_metadata(&own).is_ok() {
|
||||
return own;
|
||||
}
|
||||
let legacy = data_dir.join(LEGACY_DB_FILE);
|
||||
if schema_of(&legacy) == Some(crate::schema::SCHEMA_VERSION) {
|
||||
legacy
|
||||
} else {
|
||||
own
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema a projection file says it holds, read without writing a
|
||||
/// byte of it (an immutable read when no writer has it open; None when it
|
||||
/// isn't there, isn't a database, or says none).
|
||||
pub(crate) fn schema_of(path: &Path) -> Option<u32> {
|
||||
use rusqlite::OpenFlags;
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let conn = if path.with_extension("db-wal").exists() {
|
||||
Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
} else {
|
||||
Connection::open_with_flags(
|
||||
crate::snapshot::immutable_uri(path),
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX
|
||||
| OpenFlags::SQLITE_OPEN_URI,
|
||||
)
|
||||
}
|
||||
.ok()?;
|
||||
conn.query_row(
|
||||
"SELECT value FROM meta WHERE key='schema_version'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The lock file a writer holds while the books are open. Readers (the
|
||||
/// viewer's [`crate::Snapshot`]) never take it.
|
||||
@@ -250,10 +343,6 @@ pub struct Store {
|
||||
base_currency: Currency,
|
||||
/// The journal format in force (the format gate's record).
|
||||
format: JournalFormat,
|
||||
/// Refuse every journal feature not yet in force
|
||||
/// ([`Self::refuse_new_features`]): a stand-in for a format-upgrade
|
||||
/// gate that refuses until the owner consents.
|
||||
refuse_features: bool,
|
||||
}
|
||||
|
||||
/// One filing of bytes into the blob store and journal.
|
||||
@@ -275,24 +364,44 @@ impl Store {
|
||||
/// Create a new store. Fails if one already exists.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: start empty books in a data directory.
|
||||
/// SOURCE: docs/receipt-store.md "Line format" (genesis prev); R-11.
|
||||
/// GOAL: start empty books in a data directory, in this version's
|
||||
/// journal format.
|
||||
/// SOURCE: docs/receipt-store.md "Line format" (genesis prev); R-11;
|
||||
/// plan/served.md 3.9 and D-39 (books this version makes wait
|
||||
/// for no format upgrade).
|
||||
/// INVARIANTS:
|
||||
/// 1. The journal's first line MUST be `store.created` with the base
|
||||
/// currency, chained from the genesis hash (64 zeros).
|
||||
/// 2. The writer lock MUST be held from creation on.
|
||||
/// 3. The second line declares this version's journal format, every
|
||||
/// feature it knows (format::current()): the new books wait for
|
||||
/// no format upgrade.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF a journal already exists there: SHOULD refuse
|
||||
/// - NEVER overwrite or append to existing books.
|
||||
/// ```
|
||||
pub fn create(data_dir: &Path, base_currency: Currency) -> Result<Self, StoreError> {
|
||||
Self::create_in_format(data_dir, base_currency, &crate::format::current())
|
||||
}
|
||||
|
||||
/// [`Self::create`], declaring the journal format `format` (format 1,
|
||||
/// [`JournalFormat::default`], declares nothing): books as an older
|
||||
/// version made them, for tests of their format upgrade. Everything
|
||||
/// else creates books with [`Self::create`].
|
||||
#[doc(hidden)]
|
||||
pub fn create_in_format(
|
||||
data_dir: &Path,
|
||||
base_currency: Currency,
|
||||
format: &JournalFormat,
|
||||
) -> Result<Self, StoreError> {
|
||||
let journal_path = data_dir.join(JOURNAL_FILE);
|
||||
if journal_path.exists() {
|
||||
return Err(StoreError::AlreadyExists(data_dir.display().to_string()));
|
||||
}
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
let lock = lock_books(data_dir)?;
|
||||
let db_path = data_dir.join(DB_FILE);
|
||||
// New books: this version's own schema's file (db_file_for).
|
||||
let db_path = data_dir.join(db_file());
|
||||
let conn = Self::open_conn(&db_path)?;
|
||||
Self::migrate(&conn)?;
|
||||
Self::write_meta(&conn, "base_currency", base_currency.code())?;
|
||||
@@ -307,13 +416,16 @@ impl Store {
|
||||
ledger: Ledger::new(base_currency),
|
||||
base_currency,
|
||||
format: JournalFormat::default(),
|
||||
refuse_features: false,
|
||||
};
|
||||
let line = store.journal.append(
|
||||
"store.created",
|
||||
json!({ "base_currency": base_currency.code() }),
|
||||
)?;
|
||||
apply_line_to_sqlite(&store.conn, &line)?;
|
||||
if *format != JournalFormat::default() {
|
||||
let features: Vec<&str> = format.features.iter().map(String::as_str).collect();
|
||||
store.declare_journal_format(format.format, &features)?;
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
@@ -352,6 +464,13 @@ impl Store {
|
||||
/// for forensics and rebuild from the journal
|
||||
/// BECAUSE the database is derived; the journal is the record.
|
||||
/// 7. IF the database is behind the journal: SHOULD apply the rest.
|
||||
/// 8. The database is this version's own schema's file, or the
|
||||
/// legacy one when that already holds this version's schema
|
||||
/// ([`database_in_use`]); a file of any other schema, older or
|
||||
/// newer, is left byte for byte, never migrated, refused or
|
||||
/// deleted; this version's own missing: SHOULD build it from the
|
||||
/// journal (SCENARIO 5). One named as this version's that holds
|
||||
/// another schema is damaged (SCENARIO 6).
|
||||
/// ```
|
||||
pub fn open(data_dir: &Path) -> Result<Self, StoreError> {
|
||||
let journal_path = data_dir.join(JOURNAL_FILE);
|
||||
@@ -385,12 +504,17 @@ impl Store {
|
||||
None
|
||||
};
|
||||
let journal = JournalWriter::from_lines(&journal_path, &lines)?;
|
||||
let db_path = data_dir.join(DB_FILE);
|
||||
// This version's own schema's file (SCENARIO 8): another schema's
|
||||
// is never opened for writing.
|
||||
let db_path = database_in_use(data_dir);
|
||||
let mut fresh_db = !db_path.exists();
|
||||
let schema = crate::schema::SCHEMA_VERSION;
|
||||
let damage = if fresh_db {
|
||||
None
|
||||
} else if !Self::db_file_healthy(&db_path) {
|
||||
Some("failed integrity_check".to_string())
|
||||
} else if let Some(other) = schema_of(&db_path).filter(|v| *v != schema) {
|
||||
Some(format!("holds schema {other}, not this version's {schema}"))
|
||||
} else {
|
||||
match Self::db_currency(&db_path) {
|
||||
Some(c) if c == base_currency => None,
|
||||
@@ -433,7 +557,6 @@ impl Store {
|
||||
ledger,
|
||||
base_currency,
|
||||
format,
|
||||
refuse_features: false,
|
||||
};
|
||||
store.catch_up(&lines)?;
|
||||
if let Some(t) = torn {
|
||||
@@ -579,9 +702,35 @@ impl Store {
|
||||
&self.format
|
||||
}
|
||||
|
||||
/// The projection database's file this version uses, by name
|
||||
/// ([`database_in_use`]): what `books.verify` and `wanderbooks doctor`
|
||||
/// say.
|
||||
pub fn database_file(&self) -> String {
|
||||
file_name_of(&self.db_path)
|
||||
}
|
||||
|
||||
/// The other projection files beside it (other schemas', the legacy
|
||||
/// one if unused), by name, sorted: listed, never deleted
|
||||
/// ([`db_file_for`] INVARIANT 2).
|
||||
pub fn other_databases(&self) -> Vec<String> {
|
||||
let in_use = self.database_file();
|
||||
let mut out: Vec<String> = std::fs::read_dir(&self.data_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| is_database_file(n) && *n != in_use)
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Declare the journal format: journal a `journal.format_set` line.
|
||||
/// A writer declares the format before the first event that needs it,
|
||||
/// so an older reader stops at the declaration and says it is too old.
|
||||
/// so an older reader stops at the declaration and says it is too old:
|
||||
/// new books as they are created ([`Self::create`]), older books at
|
||||
/// their format upgrade ([`Self::upgrade_format`]), never a tool.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: books that tell every reader which format they are in.
|
||||
@@ -613,74 +762,92 @@ impl Store {
|
||||
// items, approvals (journal format 2, crate::meta)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Declare journal format 2 before the first event that needs it, so
|
||||
/// an older reader stops at the declaration (plan/served.md 5.1).
|
||||
fn ensure_format(&mut self) -> Result<(), StoreError> {
|
||||
if self.format.format < crate::meta::FORMAT {
|
||||
let features = self.format.features.clone();
|
||||
let features: Vec<&str> = features.iter().map(String::as_str).collect();
|
||||
self.declare_journal_format(crate::meta::FORMAT, &features)?;
|
||||
}
|
||||
Ok(())
|
||||
/// Refuse an event that needs journal format 2 in books that haven't
|
||||
/// declared it: books an older version wrote, waiting for their format
|
||||
/// upgrade (the owner's go-ahead, [`Self::upgrade_format`]). Books this
|
||||
/// version made declared it when they were created.
|
||||
fn ensure_format(&self) -> Result<(), StoreError> {
|
||||
self.format.require_format(crate::meta::FORMAT)
|
||||
}
|
||||
|
||||
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
|
||||
/// first line that needs it: a new event type, or a field or value on
|
||||
/// a known event type that an older version would otherwise misread
|
||||
/// silently. Declared with the format in force (at least format 2,
|
||||
/// crate::meta::FORMAT) and every feature already in force, so an
|
||||
/// older reader stops at the declaration (rules.md B-27); nothing is
|
||||
/// journaled when it is already declared. Books that never use the
|
||||
/// feature stay readable by older versions.
|
||||
/// Refuse a write that needs the journal feature `feature`
|
||||
/// (crate::format::KNOWN_FEATURES: a field or value on an event type an
|
||||
/// older version knows, which that version would misread silently)
|
||||
/// unless the books declared it. Books this version made declared it
|
||||
/// when they were created; older books declare it only at their format
|
||||
/// upgrade, with every other feature this version knows, never one at
|
||||
/// a time and never without the owner's go-ahead
|
||||
/// ([`Self::upgrade_format`]). Ask it only for a write that uses the
|
||||
/// feature (the line about to be journaled holds the new field or
|
||||
/// value): a write that doesn't is never refused, since declining the
|
||||
/// upgrade keeps everything else working (D-39).
|
||||
///
|
||||
/// Each command here that writes a feature's line declares it first.
|
||||
/// A caller that journals several commands as one (a tool drafting an
|
||||
/// Each command here that writes a feature's line asks for it first. A
|
||||
/// caller that journals several commands as one (a tool drafting an
|
||||
/// entry and tagging it) calls this before the first of them, once
|
||||
/// every check has passed: a declaration never lands after another
|
||||
/// line of the same command (where a refusal to upgrade the format
|
||||
/// would leave the command half done), and a refused command leaves
|
||||
/// none.
|
||||
/// every check has passed: a refusal never lands after another line of
|
||||
/// the same command (which would leave the command half done), and a
|
||||
/// refused command leaves none.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
|
||||
/// instead of misreading, or failing on, a line it can't read.
|
||||
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
|
||||
/// instead of misreading a line it thinks it knows, and books
|
||||
/// move past what their owner's older version reads only when
|
||||
/// the owner says so.
|
||||
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate), 3.9 and
|
||||
/// D-39 (the format upgrade).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the books declared it: SHOULD pass.
|
||||
/// 2. IF not: SHOULD refuse (FormatUpgradePending), journaling
|
||||
/// nothing.
|
||||
/// ```
|
||||
pub fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
self.ensure_features(&[feature])
|
||||
pub fn ensure_feature(&self, feature: &str) -> Result<(), StoreError> {
|
||||
debug_assert!(
|
||||
crate::format::KNOWN_FEATURES.contains(&feature),
|
||||
"{feature} isn't a feature this version knows"
|
||||
);
|
||||
self.format.require_feature(feature)
|
||||
}
|
||||
|
||||
/// [`Self::ensure_feature`] for several at once: one declaration
|
||||
/// naming every one not yet in force, or none when all are.
|
||||
fn ensure_features(&mut self, needed: &[&str]) -> Result<(), StoreError> {
|
||||
let mut features: Vec<String> = self.format.features.clone();
|
||||
let before = features.len();
|
||||
for f in needed {
|
||||
if !features.iter().any(|g| g == f) {
|
||||
features.push(f.to_string());
|
||||
}
|
||||
}
|
||||
if features.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
if self.refuse_features {
|
||||
return Err(StoreError::FormatRefused(format!(
|
||||
"journal features {:?}: these books take no new feature now",
|
||||
&features[before..]
|
||||
)));
|
||||
}
|
||||
let format = self.format.format.max(crate::meta::FORMAT);
|
||||
let features: Vec<&str> = features.iter().map(String::as_str).collect();
|
||||
self.declare_journal_format(format, &features)
|
||||
/// The format upgrade (plan/served.md 3.9, D-39): declare this
|
||||
/// version's journal format, with every feature it knows, in one
|
||||
/// `journal.format_set`, for books an older version wrote. What was
|
||||
/// upgraded; None (journaling nothing) when the books already are in
|
||||
/// this version's format. The books' figures don't change: only what
|
||||
/// may be written from here on.
|
||||
///
|
||||
/// Only the owner's go-ahead comes here: the books server, after the
|
||||
/// owner passphrase and a verified backup with the keys (ledger-mcp
|
||||
/// local.rs and backup.rs); and an adoption, whose copy is new books
|
||||
/// of this version while the original keeps its own format (root.rs).
|
||||
/// No tool does.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: older books move to this version's format at once, on the
|
||||
/// record, exactly once.
|
||||
/// SOURCE: plan/served.md 3.9 ("Format changes are the server's
|
||||
/// gate"); D-39; rules.md B-27.
|
||||
/// INVARIANTS:
|
||||
/// 1. One journal line declares the whole format: the format moves
|
||||
/// exactly once, never feature by feature.
|
||||
/// 2. Books already in this version's format: nothing is journaled.
|
||||
/// 3. The ledger is unchanged: every report reads as before.
|
||||
/// ```
|
||||
pub fn upgrade_format(&mut self) -> Result<Option<crate::format::FormatUpgrade>, StoreError> {
|
||||
let Some(upgrade) = self.format.upgrade() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let features: Vec<&str> = upgrade.to.features.iter().map(String::as_str).collect();
|
||||
self.declare_journal_format(upgrade.to.format, &features)?;
|
||||
debug_assert_eq!(self.format, upgrade.to);
|
||||
Ok(Some(upgrade))
|
||||
}
|
||||
|
||||
/// Refuse (`refuse: true`) or take again every journal feature not yet
|
||||
/// in force, as a format-upgrade gate does that waits for the owner's
|
||||
/// consent: what a command does when [`Self::ensure_feature`] refuses
|
||||
/// (it journals nothing). Not saved: the books open taking features.
|
||||
#[doc(hidden)]
|
||||
pub fn refuse_new_features(&mut self, refuse: bool) {
|
||||
self.refuse_features = refuse;
|
||||
/// [`Self::ensure_feature`] for each feature a write needs: refused
|
||||
/// (FormatUpgradePending, journaling nothing) unless the books declared
|
||||
/// every one.
|
||||
fn ensure_features(&self, needed: &[&str]) -> Result<(), StoreError> {
|
||||
needed.iter().try_for_each(|f| self.ensure_feature(f))
|
||||
}
|
||||
|
||||
/// Change settings (`settings.set`): all or nothing, each value
|
||||
@@ -719,9 +886,10 @@ impl Store {
|
||||
if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) {
|
||||
return Ok(changes);
|
||||
}
|
||||
// A key an older version doesn't know needs its feature declared
|
||||
// first (crate::format::setting_feature), in the one declaration
|
||||
// that also brings the books to format 2.
|
||||
// A key an older version doesn't know needs its feature
|
||||
// (crate::format::setting_feature), and every settings.set format
|
||||
// 2: refused, journaling nothing, in books that haven't declared
|
||||
// them (their format upgrade does).
|
||||
let needed: Vec<&str> = changes
|
||||
.iter()
|
||||
.filter_map(|c| crate::format::setting_feature(&c.key))
|
||||
@@ -1451,10 +1619,12 @@ impl Store {
|
||||
/// Name a party the creditor of a liability account the company owes
|
||||
/// it itself on, or stop (`on` false): ledger_core's
|
||||
/// `Ledger::set_party_creditor`. Journaled as `party.creditor_set
|
||||
/// {id, account_id, on}`, after the journal declares the feature it
|
||||
/// needs ([`crate::format::PARTY_CREDITORS`]) the first time, so an
|
||||
/// older wanderbooks stops there and says it is too old instead of
|
||||
/// misreading the books.
|
||||
/// {id, account_id, on}`, which needs the journal feature
|
||||
/// [`crate::format::PARTY_CREDITORS`], so an older wanderbooks stops at
|
||||
/// the declaration and says it is too old instead of misreading the
|
||||
/// books. Books that haven't declared it (an older version's, until
|
||||
/// their format upgrade) refuse it, FormatUpgradePending, journaling
|
||||
/// nothing.
|
||||
pub fn set_party_creditor(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
@@ -1820,8 +1990,10 @@ impl Store {
|
||||
/// 3. Nothing due (the month rounds to nothing, a later run caught it
|
||||
/// up, the life is over and posted) journals nothing: an entry
|
||||
/// can't carry a zero line (P3 depreciation review R10).
|
||||
/// 4. A period run before and reversed is run again only after
|
||||
/// journal feature month_reposting is declared.
|
||||
/// 4. A period run before and reversed is run again only in books
|
||||
/// that declare journal feature month_reposting (books an older
|
||||
/// version wrote: after their format upgrade); refused before
|
||||
/// anything is journaled otherwise.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset is unknown or disposed, the period malformed, or
|
||||
/// it ends before the asset was placed in service: SHOULD refuse.
|
||||
@@ -2344,8 +2516,8 @@ impl Store {
|
||||
)?;
|
||||
// The disposal month's run was reversed (else the final
|
||||
// depreciation would be in the disposal entry): its line again,
|
||||
// which needs a journal feature, declared (or refused) before the
|
||||
// first line is journaled.
|
||||
// which needs a journal feature, asked for (and refused in books
|
||||
// that haven't declared it) before the first line is journaled.
|
||||
if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period)? {
|
||||
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
|
||||
}
|
||||
@@ -2447,17 +2619,19 @@ impl Store {
|
||||
// Rehearsed whole: a doomed close journals nothing, not even the
|
||||
// Retained Earnings account creation below.
|
||||
self.rehearse(|l| l.close_period_to(id, to))?;
|
||||
if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) {
|
||||
// A close counted from its regime's start: an older version
|
||||
// would replay it under the run rule (format gate, B-27).
|
||||
// Refused before anything is journaled until the books
|
||||
// declare it.
|
||||
self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?;
|
||||
}
|
||||
if matches!(to, CloseTo::RetainedEarnings { .. }) {
|
||||
// Retained Earnings must exist before the close so its creation
|
||||
// (if needed) is journaled as account.added; the close's own
|
||||
// ensure then no-ops and replay stays deterministic.
|
||||
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
|
||||
}
|
||||
if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) {
|
||||
// A close counted from its regime's start: an older version
|
||||
// would replay it under the run rule (format gate, B-27).
|
||||
self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?;
|
||||
}
|
||||
let mut next = self.ledger.clone();
|
||||
let closing_entry = next.close_period_to(id, to)?;
|
||||
let entry_detail = match closing_entry {
|
||||
@@ -3315,10 +3489,11 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Register an amortization schedule. Journaled as `schedule.registered`,
|
||||
/// with its `convention` (`daily` or `monthly_even`). The first daily
|
||||
/// schedule declares journal feature `schedule_convention` before its
|
||||
/// line: an older version reads every schedule as equal months, and
|
||||
/// would refuse a daily one's postings as wrong (rather than as newer).
|
||||
/// with its `convention` (`daily` or `monthly_even`). A daily schedule
|
||||
/// needs journal feature `schedule_convention` (refused, journaling
|
||||
/// nothing, in books that haven't declared it): an older version reads
|
||||
/// every schedule as equal months, and would refuse a daily one's
|
||||
/// postings as wrong (rather than as newer).
|
||||
pub fn register_schedule(
|
||||
&mut self,
|
||||
new: ledger_core::NewSchedule,
|
||||
@@ -3352,8 +3527,9 @@ impl Store {
|
||||
/// Post one month of a schedule. Journaled as `schedule.posted` with the
|
||||
/// whole entry, so a month is never half-posted or posted twice; replay
|
||||
/// re-derives the entry and checks it line by line. A month a reversal
|
||||
/// un-posted posts again only after journal feature `month_reposting` is
|
||||
/// declared: an older version would refuse it as posted twice.
|
||||
/// un-posted posts again only in books that declare journal feature
|
||||
/// `month_reposting` (refused, journaling nothing, otherwise): an older
|
||||
/// version would refuse it as posted twice.
|
||||
pub fn post_schedule_month(
|
||||
&mut self,
|
||||
id: ledger_core::ScheduleId,
|
||||
@@ -3535,10 +3711,9 @@ impl Store {
|
||||
/// Give an account a new name (see `Ledger::rename_account`), with an
|
||||
/// optional reason. Journaled as `account.renamed`, keyed by the
|
||||
/// account's id (a code can change; an id can't), with its code, the
|
||||
/// new name, the name it had and the reason. The first rename declares
|
||||
/// the journal feature `account_renamed`
|
||||
/// ([`crate::format::FEATURE_ACCOUNT_RENAMED`]), so an earlier version
|
||||
/// stops at the declaration.
|
||||
/// new name, the name it had and the reason. It needs the journal
|
||||
/// feature `account_renamed` ([`crate::format::FEATURE_ACCOUNT_RENAMED`]),
|
||||
/// so an earlier version stops at the declaration.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: rename an account, keeping the name it had in the journal.
|
||||
@@ -3549,8 +3724,9 @@ impl Store {
|
||||
/// 1. Only the account's name changes (Ledger::rename_account
|
||||
/// INVARIANT 1): every entry, line, hash and earlier journal line
|
||||
/// stays as it was.
|
||||
/// 2. Rehearsed and checked first: a refusal journals nothing, not
|
||||
/// even the feature's declaration.
|
||||
/// 2. Rehearsed and checked first: a refusal journals nothing (and
|
||||
/// books that haven't declared `account_renamed` refuse it,
|
||||
/// FormatUpgradePending, until their format upgrade).
|
||||
/// ```
|
||||
pub fn rename_account(
|
||||
&mut self,
|
||||
@@ -3659,7 +3835,9 @@ impl Store {
|
||||
/// 2. IF the core refuses it: SHOULD refuse, journaling nothing.
|
||||
/// 3. IF it changes no decision: SHOULD journal nothing but a
|
||||
/// changed W-9 flag (Store::set_vendor_w9_flag).
|
||||
/// 4. Otherwise: SHOULD declare the feature once, then journal it.
|
||||
/// 4. Otherwise: SHOULD journal it, in books that declare the feature
|
||||
/// (refused, FormatUpgradePending, journaling nothing, in books
|
||||
/// that don't).
|
||||
/// ```
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
@@ -4638,6 +4816,8 @@ impl Store {
|
||||
.map(|l| sha256_hex(l.to_canonical().as_bytes()))
|
||||
.unwrap_or_else(|| GENESIS_PREV.to_string()),
|
||||
healed,
|
||||
database: self.database_file(),
|
||||
other_databases: self.other_databases(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6960,6 +7140,19 @@ pub struct VerifyReport {
|
||||
pub head: String,
|
||||
/// Why the projection was rebuilt, if it was.
|
||||
pub healed: Option<String>,
|
||||
/// The projection database's file verified: this version's
|
||||
/// ([`Store::database_file`]).
|
||||
pub database: String,
|
||||
/// Other schemas' projection files beside it, left as they are
|
||||
/// ([`Store::other_databases`]).
|
||||
pub other_databases: Vec<String>,
|
||||
}
|
||||
|
||||
/// A path's last part, as text.
|
||||
fn file_name_of(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A projection of `lines` into a private in-memory database, built with
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, AssetStatus, Currency, Money, NewAsset};
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, Store};
|
||||
use ledger_store::{JOURNAL_FILE, Store, db_file};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -309,10 +309,10 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
|
||||
|
||||
/// Copy the books (journal and projection) to `to`, closed.
|
||||
fn copy_books(from: &Path, to: &Path) {
|
||||
for f in [JOURNAL_FILE, DB_FILE] {
|
||||
let src = from.join(f);
|
||||
for f in [JOURNAL_FILE.to_string(), db_file()] {
|
||||
let src = from.join(&f);
|
||||
if src.exists() {
|
||||
std::fs::copy(&src, to.join(f)).unwrap();
|
||||
std::fs::copy(&src, to.join(&f)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! written before tags existed, refusals journaling nothing, and corrupt
|
||||
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
|
||||
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
|
||||
//! v10 migration, corrupt payloads).
|
||||
//! v10 schema, corrupt payloads). A database at an older schema under this
|
||||
//! version's file name is rebuilt, never migrated (each schema keeps a file
|
||||
//! of its own: rules.md B-27, tests/schema_files.rs).
|
||||
//!
|
||||
//! Expectations come from the doc comments on `Store::set_asset_tag`,
|
||||
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
|
||||
@@ -14,7 +16,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, AssetId, Currency, LedgerError, Money, NewAsset};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store, StoreError};
|
||||
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError, db_file};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -245,7 +247,7 @@ fn tags_and_serials_replay_on_reopen() {
|
||||
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
@@ -327,7 +329,7 @@ fn verify_passes_after_many_retags_and_serial_changes() {
|
||||
fn books_written_before_tags_open_and_verify() {
|
||||
let (dir, s, _, x, y) = fixture("older");
|
||||
drop(s);
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(db_file()));
|
||||
let mut s = Store::open(&dir).expect("older books still open");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x), None);
|
||||
@@ -356,7 +358,7 @@ fn an_older_asset_registered_line_still_replays() {
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(db_file()));
|
||||
let mut s = Store::open(&dir).expect("older books still open");
|
||||
s.verify().unwrap();
|
||||
let old = y + 1;
|
||||
@@ -372,12 +374,23 @@ fn an_older_asset_registered_line_still_replays() {
|
||||
assert_eq!(tag_of(&s, old).as_deref(), Some("FL-0001"));
|
||||
}
|
||||
|
||||
/// Whether this version's database was kept aside (and rebuilt) as the
|
||||
/// books opened: `wanderbooks.v{N}.corrupt.*` beside it.
|
||||
fn kept_aside(dir: &Path) -> bool {
|
||||
let stem = db_file().trim_end_matches(".db").to_string();
|
||||
std::fs::read_dir(dir).unwrap().flatten().any(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(&format!("{stem}.corrupt."))
|
||||
})
|
||||
}
|
||||
|
||||
/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no
|
||||
/// asset_tags_log, and none of v10's tax basis columns), as an older
|
||||
/// wanderbooks left it.
|
||||
fn downgrade_db_to_v8(dir: &Path) {
|
||||
downgrade_db_to_v9(dir);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE assets DROP COLUMN tag;
|
||||
ALTER TABLE assets DROP COLUMN serial;
|
||||
@@ -388,13 +401,17 @@ fn downgrade_db_to_v8(dir: &Path) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An existing v8 database migrates to v9 on open and verifies.
|
||||
/// A database at schema v8 under this version's own file name (what an
|
||||
/// older wanderbooks once migrated in place) is never migrated now: each
|
||||
/// schema keeps a file of its own (rules.md B-27), so this one is damaged,
|
||||
/// kept aside and rebuilt from the journal as the books open, and verifies.
|
||||
#[test]
|
||||
fn a_v8_database_migrates_and_verifies() {
|
||||
let (dir, s, _, x, y) = fixture("v8");
|
||||
drop(s);
|
||||
downgrade_db_to_v8(&dir);
|
||||
let mut s = Store::open(&dir).expect("a v8 database opens");
|
||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||
s.verify().unwrap();
|
||||
s.set_asset_tag(x, "FL-0001").unwrap();
|
||||
s.set_asset_serial(y, Some("SN-Y")).unwrap();
|
||||
@@ -406,8 +423,8 @@ fn a_v8_database_migrates_and_verifies() {
|
||||
assert_eq!(serial_of(&s, y).as_deref(), Some("SN-Y"));
|
||||
}
|
||||
|
||||
/// A v8 database behind a journal that has tag lines (Store::open SCENARIO
|
||||
/// 7: apply the rest) migrates, applies them, and verifies.
|
||||
/// A v8 database behind a journal that has tag lines is rebuilt from the
|
||||
/// whole journal, the tag lines with it (as above), and verifies.
|
||||
#[test]
|
||||
fn a_v8_database_behind_a_journal_with_tags_catches_up() {
|
||||
let (dir, s, _, x, _) = fixture("v8-behind");
|
||||
@@ -423,6 +440,7 @@ fn a_v8_database_behind_a_journal_with_tags_catches_up() {
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let s = Store::open(&dir).expect("opens and catches up");
|
||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
|
||||
assert_eq!(serial_of(&s, x).as_deref(), Some("SN-1"));
|
||||
@@ -449,7 +467,7 @@ fn an_asset_serial_set_line_with_a_non_string_serial_is_corrupt() {
|
||||
Ok(_) => panic!("serial {bad}: a corrupt asset.serial_set line opened"),
|
||||
}
|
||||
// Rebuilding from the journal alone refuses it too.
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(db_file()));
|
||||
assert!(Store::open(&dir).is_err(), "serial {bad}: rebuild opened");
|
||||
}
|
||||
}
|
||||
@@ -497,7 +515,7 @@ fn the_acquisition_link_replays_and_verifies() {
|
||||
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
|
||||
s.verify().unwrap();
|
||||
@@ -542,7 +560,7 @@ fn a_description_change_replays_and_verifies() {
|
||||
assert_eq!(p["description"], "Laptop, contributed jointly");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
s.ledger().get_asset(x).unwrap().description,
|
||||
@@ -673,7 +691,7 @@ fn tax_basis_replays_on_reopen() {
|
||||
drop(s);
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
@@ -694,7 +712,7 @@ fn tax_basis_replays_on_reopen() {
|
||||
/// Downgrade the database to schema v10 (none of v11's attachment links,
|
||||
/// statements, reconciliations, or payroll_lines.employee_id).
|
||||
fn downgrade_db_to_v10(dir: &Path) {
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE attachment_links;
|
||||
DROP TABLE statements;
|
||||
@@ -706,7 +724,8 @@ fn downgrade_db_to_v10(dir: &Path) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An existing v10 database migrates on open and verifies.
|
||||
/// A database at schema v10 under this version's own file name is rebuilt
|
||||
/// from the journal as the books open (as the v8 one above), and verifies.
|
||||
#[test]
|
||||
fn a_v10_database_migrates_and_verifies() {
|
||||
let (dir, mut s, _, x, _) = fixture("v10");
|
||||
@@ -714,6 +733,7 @@ fn a_v10_database_migrates_and_verifies() {
|
||||
drop(s);
|
||||
downgrade_db_to_v10(&dir);
|
||||
let s = Store::open(&dir).expect("a v10 database opens");
|
||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
|
||||
}
|
||||
@@ -722,7 +742,7 @@ fn a_v10_database_migrates_and_verifies() {
|
||||
/// assets.contributor_basis), as an older wanderbooks left it.
|
||||
fn downgrade_db_to_v9(dir: &Path) {
|
||||
downgrade_db_to_v10(dir);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE assets DROP COLUMN tax_rule;
|
||||
ALTER TABLE assets DROP COLUMN contributor_basis;
|
||||
@@ -731,7 +751,8 @@ fn downgrade_db_to_v9(dir: &Path) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An existing v9 database migrates on open, verifies, and takes tax bases.
|
||||
/// A database at schema v9 under this version's own file name is rebuilt
|
||||
/// as the books open (as the v8 one above), verifies, and takes tax bases.
|
||||
#[test]
|
||||
fn a_v9_database_migrates_and_verifies() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
@@ -740,6 +761,7 @@ fn a_v9_database_migrates_and_verifies() {
|
||||
drop(s);
|
||||
downgrade_db_to_v9(&dir);
|
||||
let mut s = Store::open(&dir).expect("a v9 database opens");
|
||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
|
||||
assert_eq!(tax_of(&s, x), None);
|
||||
@@ -752,8 +774,8 @@ fn a_v9_database_migrates_and_verifies() {
|
||||
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
|
||||
}
|
||||
|
||||
/// A v9 database behind a journal with a tax basis line migrates, applies
|
||||
/// it (Store::open SCENARIO 7), and verifies.
|
||||
/// A v9 database behind a journal with a tax basis line is rebuilt from
|
||||
/// the whole journal, that line with it, and verifies.
|
||||
#[test]
|
||||
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
@@ -768,6 +790,7 @@ fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let s = Store::open(&dir).expect("opens and catches up");
|
||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
|
||||
}
|
||||
@@ -799,7 +822,7 @@ fn an_asset_tax_basis_set_line_with_a_bad_payload_is_corrupt() {
|
||||
Err(other) => panic!("{bad}: expected JournalCorrupt, got {other:?}"),
|
||||
Ok(_) => panic!("{bad}: a corrupt asset.tax_basis_set line opened"),
|
||||
}
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(db_file()));
|
||||
match Store::open(&dir) {
|
||||
Err(StoreError::JournalCorrupt(_)) => {}
|
||||
Err(other) => panic!("{bad} rebuild: expected JournalCorrupt, got {other:?}"),
|
||||
@@ -835,7 +858,7 @@ fn an_asset_is_corrected_until_depreciation_is_posted() {
|
||||
assert_eq!(p["previous"]["cost"]["amount"], "1200.00");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
s.ledger().get_asset(x).unwrap().cost.amount.to_string(),
|
||||
|
||||
@@ -396,9 +396,12 @@ fn a_skipped_month_is_caught_up_in_the_month_run() {
|
||||
}
|
||||
|
||||
/// P3 depreciation review R3: a run reversed within its month is
|
||||
/// un-posted, so its month is due again; running it again declares journal
|
||||
/// feature month_reposting first (an older version would ignore the second
|
||||
/// line for the month).
|
||||
/// un-posted, so its month is due again; running it again needs journal
|
||||
/// feature month_reposting (an older version would ignore the second line
|
||||
/// for the month), which new books declare as they are created, so it
|
||||
/// journals no declaration of its own (books an older version wrote wait
|
||||
/// for their format upgrade: format.rs
|
||||
/// a_write_waiting_for_the_upgrade_journals_nothing).
|
||||
#[test]
|
||||
fn a_reversed_depreciation_month_can_be_run_again() {
|
||||
let (dir, mut store) = open_store();
|
||||
@@ -409,14 +412,28 @@ fn a_reversed_depreciation_month_can_be_run_again() {
|
||||
store
|
||||
.reverse_entry(jan_entry, date(2026, 1, 31), "posted to the wrong asset")
|
||||
.unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
assert!(
|
||||
store
|
||||
.journal_format()
|
||||
.has_feature(ledger_store::format::MONTH_REPOSTING)
|
||||
);
|
||||
let declarations = |dir: &std::path::Path| {
|
||||
std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|l| l.contains("journal.format_set"))
|
||||
.count()
|
||||
};
|
||||
let before = declarations(dir.path());
|
||||
let again = store.run_depreciation(x, "2026-01").unwrap();
|
||||
assert!(!again.already_posted);
|
||||
assert_eq!(again.amount, dec("1000.00"));
|
||||
assert_ne!(again.entry_id, Some(jan_entry));
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
declarations(dir.path()),
|
||||
before,
|
||||
"no declaration of its own"
|
||||
);
|
||||
// Idempotent again: the new run is the month's.
|
||||
let twice = store.run_depreciation(x, "2026-01").unwrap();
|
||||
@@ -430,7 +447,7 @@ fn a_reversed_depreciation_month_can_be_run_again() {
|
||||
// Reopened (the projection rebuilt from the journal, then caught up),
|
||||
// the same.
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.path().join(ledger_store::db_file())).unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.verify().unwrap();
|
||||
let mut store = store;
|
||||
@@ -631,7 +648,7 @@ fn a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month() {
|
||||
);
|
||||
assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00"));
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
@@ -854,7 +871,7 @@ fn a_run_reinstated_after_its_rerun_was_reversed_still_counts() {
|
||||
store.verify().unwrap();
|
||||
// The same after the projection is rebuilt from the journal.
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.path().join(ledger_store::db_file())).unwrap();
|
||||
Store::open(dir.path()).unwrap().verify().unwrap();
|
||||
}
|
||||
|
||||
@@ -1069,7 +1086,7 @@ fn a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry() {
|
||||
.filter(|l| l.contains("asset.depreciation_posted") && l.contains("\"2026-06\""))
|
||||
.count();
|
||||
assert_eq!(junes, 1, "June keeps one run");
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
let on = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"));
|
||||
|
||||
@@ -17,7 +17,7 @@ use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::store::{Reconciliation, Statement, Subject};
|
||||
use ledger_store::{Attachment, DB_FILE, JOURNAL_FILE, Store, StoreError};
|
||||
use ledger_store::{Attachment, JOURNAL_FILE, Store, StoreError, db_file};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -137,7 +137,7 @@ fn refused_nothing_journaled<T: std::fmt::Debug>(
|
||||
|
||||
fn delete_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T15:47:13Z","type":"store.created"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"f7654c1569eb4937590b36306bc57c162d23109ec5e73cfe1eb2a25f2a56613e","seq":2,"ts":"2026-10-21T15:47:13Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"0365a319dcc4a38d8ea85a0aa18cffa6d3507c85f7999ec676e868e577f5c4a8","seq":3,"ts":"2026-10-21T15:47:13Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-03-01","description":"Capital in","id":1},"prev":"706e1ee9e5804f21858822f83e55cdb91e3b6f7b122929944adf78639239e963","seq":4,"ts":"2026-10-21T15:47:13Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"500","currency":"USD","id":1,"side":"debit"},"prev":"1a77b7d31e6394b71a2ea90ca1c059e82bffda1223ac009776ab94719a299b1b","seq":5,"ts":"2026-10-21T15:47:13Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"500","currency":"USD","id":1,"side":"credit"},"prev":"7b976b885597c115457545dfc2c1ba21df432796941acc8ecdf971254fa0c0e8","seq":6,"ts":"2026-10-21T15:47:13Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-03-01","description":"Capital in","id":1,"lines":[{"account":1,"amount":"500","currency":"USD","side":"debit"},{"account":2,"amount":"500","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0bf713aa4db27c948592d5e772ea49414d95d068891fa525874e3eddfcf82796","seq":7,"ts":"2026-10-21T15:47:13Z","type":"entry.posted"}
|
||||
Binary file not shown.
@@ -1,8 +1,9 @@
|
||||
//! 1099 decisions and payment methods in the journal (plan P3, defects
|
||||
//! 12, 18 and 23; D-40): declared as features before their first line,
|
||||
//! replayed and projected exactly, refused without a trace, and books
|
||||
//! written before them read as they always meant. Every name is
|
||||
//! fictional.
|
||||
//! 12, 18 and 23; D-40): written only in books that declare their
|
||||
//! features (this version's from creation, an older version's after the
|
||||
//! format upgrade), replayed and projected exactly, refused without a
|
||||
//! trace, and books written before them read as they always meant. Every
|
||||
//! name is fictional.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the decision ("no 1099" included) and how each payment was made
|
||||
@@ -17,9 +18,11 @@ use ledger_core::{
|
||||
AccountType, Currency, Dated1099, Decision1099, ExemptReason, Form1099, InstrumentKind, Money,
|
||||
Side,
|
||||
};
|
||||
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS};
|
||||
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS, current};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
|
||||
use ledger_store::{
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, db_file,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -76,7 +79,13 @@ struct Books {
|
||||
}
|
||||
|
||||
fn books(dir: &Path) -> Books {
|
||||
let mut store = Store::create(dir, Currency::USD).unwrap();
|
||||
books_in(dir, ¤t())
|
||||
}
|
||||
|
||||
/// [`books`] in the journal format `format`: format 1
|
||||
/// ([`JournalFormat::default`]) is books an older version wrote.
|
||||
fn books_in(dir: &Path, format: &JournalFormat) -> Books {
|
||||
let mut store = Store::create_in_format(dir, Currency::USD, format).unwrap();
|
||||
store
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
@@ -130,26 +139,57 @@ fn types(dir: &Path) -> Vec<(String, serde_json::Value)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// B-27: the first line that needs a feature comes after a declaration of
|
||||
/// it (with every feature already in force), once. A version that doesn't
|
||||
/// know the feature stops at the declaration (`tests/format.rs`
|
||||
/// B-27: a line that needs a feature is written only in books that
|
||||
/// declare it, and a command never declares one on the way. Books an
|
||||
/// older version wrote (format 1) refuse a decision and a payment method
|
||||
/// while they wait for their format upgrade, journaling nothing; the
|
||||
/// upgrade is one declaration of every feature; after it, as in books this
|
||||
/// version made (which declared them when created), each command journals
|
||||
/// its own lines and nothing else. A version that doesn't know a feature
|
||||
/// stops at the declaration (`tests/format.rs`
|
||||
/// old_reader_gets_newer_journal, for any feature it doesn't know).
|
||||
#[test]
|
||||
fn each_feature_is_declared_once_before_its_first_line() {
|
||||
fn each_feature_waits_for_the_upgrade_and_is_never_declared_on_the_way() {
|
||||
let dir = test_dir("declare");
|
||||
let mut b = books(&dir);
|
||||
let before = types(&dir).len();
|
||||
let mut b = books_in(&dir, &JournalFormat::default());
|
||||
let was = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
assert!(
|
||||
!types(&dir).iter().any(|(t, _)| t == FORMAT_SET),
|
||||
"books without either feature declare nothing"
|
||||
"books an older version wrote declare nothing"
|
||||
);
|
||||
b.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&from_beginning(Decision1099::Files(Form1099::Nec)),
|
||||
let nec = from_beginning(Decision1099::Files(Form1099::Nec));
|
||||
fn waits<T: std::fmt::Debug>(r: Result<T, StoreError>, feature: &str) {
|
||||
assert!(
|
||||
matches!(&r, Err(StoreError::FormatUpgradePending { need }) if need.contains(feature)),
|
||||
"{feature}: {r:?}"
|
||||
);
|
||||
}
|
||||
waits(
|
||||
b.store.set_vendor_1099(b.harbor, &nec, None),
|
||||
VENDOR_1099_DECISIONS,
|
||||
);
|
||||
waits(
|
||||
b.store.set_vendor_w9_flag(b.harbor, true),
|
||||
VENDOR_1099_DECISIONS,
|
||||
);
|
||||
waits(
|
||||
b.store.add_instrument(
|
||||
InstrumentKind::MemberCard,
|
||||
"Riley's Visa",
|
||||
Some("4242"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
b.due,
|
||||
),
|
||||
PAYMENT_INSTRUMENTS,
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap(),
|
||||
was,
|
||||
"nothing journaled"
|
||||
);
|
||||
let before = types(&dir).len();
|
||||
assert!(b.store.upgrade_format().unwrap().is_some());
|
||||
b.store.set_vendor_1099(b.harbor, &nec, None).unwrap();
|
||||
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
|
||||
let card = b
|
||||
.store
|
||||
@@ -171,26 +211,48 @@ fn each_feature_is_declared_once_before_its_first_line() {
|
||||
b.due,
|
||||
)
|
||||
.unwrap();
|
||||
let added: Vec<String> = types(&dir)[before..]
|
||||
.iter()
|
||||
.map(|(t, p)| match t.as_str() {
|
||||
FORMAT_SET => format!("{t} {}", p["features"]),
|
||||
_ => t.clone(),
|
||||
})
|
||||
.collect();
|
||||
let j = types(&dir);
|
||||
let added: Vec<&str> = j[before..].iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
added,
|
||||
[
|
||||
format!("{FORMAT_SET} [\"{VENDOR_1099_DECISIONS}\"]"),
|
||||
"vendor.1099_set".to_string(),
|
||||
"vendor.w9_flag_set".to_string(),
|
||||
format!("{FORMAT_SET} [\"{PAYMENT_INSTRUMENTS}\",\"{VENDOR_1099_DECISIONS}\"]"),
|
||||
"instrument.added".to_string(),
|
||||
"entry.instrument_set".to_string(),
|
||||
"instrument.added".to_string(),
|
||||
FORMAT_SET,
|
||||
"vendor.1099_set",
|
||||
"vendor.w9_flag_set",
|
||||
"instrument.added",
|
||||
"entry.instrument_set",
|
||||
"instrument.added",
|
||||
]
|
||||
);
|
||||
assert_eq!(b.store.journal_format().format, 2);
|
||||
let declared = &j[before].1["features"];
|
||||
for feature in [VENDOR_1099_DECISIONS, PAYMENT_INSTRUMENTS] {
|
||||
assert!(
|
||||
declared.as_array().unwrap().contains(&json!(feature)),
|
||||
"the upgrade declares {feature}"
|
||||
);
|
||||
}
|
||||
assert_eq!(b.store.journal_format(), ¤t());
|
||||
b.store.verify().unwrap();
|
||||
|
||||
// Books this version made: declared at creation, so the same commands
|
||||
// journal only their own lines.
|
||||
let dir = test_dir("declare-new");
|
||||
let mut b = books(&dir);
|
||||
let before = types(&dir).len();
|
||||
b.store.set_vendor_1099(b.harbor, &nec, None).unwrap();
|
||||
b.store
|
||||
.add_instrument(
|
||||
InstrumentKind::MemberCashOrCheck,
|
||||
"Riley's checks",
|
||||
None,
|
||||
None,
|
||||
b.due,
|
||||
)
|
||||
.unwrap();
|
||||
let j = types(&dir);
|
||||
let added: Vec<&str> = j[before..].iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(added, ["vendor.1099_set", "instrument.added"]);
|
||||
assert_eq!(j.iter().filter(|(t, _)| t == FORMAT_SET).count(), 1);
|
||||
}
|
||||
|
||||
/// Every record replays and projects exactly: reopening gives back the
|
||||
@@ -252,14 +314,14 @@ fn decisions_and_methods_replay_and_project() {
|
||||
assert!(live.differs_from(store.ledger()).is_empty());
|
||||
store.verify().unwrap();
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
assert!(live.differs_from(store.ledger()).is_empty());
|
||||
drop(store);
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert!(live.differs_from(snap.ledger()).is_empty());
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
let decisions: Vec<DecisionRow> = conn
|
||||
.prepare("SELECT effective, form_1099, reason_kind, source_json FROM vendor_1099_decisions ORDER BY seq")
|
||||
.unwrap()
|
||||
@@ -478,7 +540,7 @@ fn an_unknown_1099_value_is_newer_not_corrupt() {
|
||||
.unwrap();
|
||||
let seq = w.append(t, payload).unwrap().seq;
|
||||
drop(w);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
match (Store::open(&dir), need) {
|
||||
(
|
||||
Err(StoreError::NewerJournal {
|
||||
@@ -502,7 +564,7 @@ fn an_unknown_1099_value_is_newer_not_corrupt() {
|
||||
#[test]
|
||||
fn books_written_before_decisions_read_as_they_always_meant() {
|
||||
let dir = test_dir("legacy");
|
||||
let b = books(&dir);
|
||||
let b = books_in(&dir, &JournalFormat::default());
|
||||
let (harbor, paid) = (b.harbor, b.paid);
|
||||
drop(b);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
@@ -519,7 +581,7 @@ fn books_written_before_decisions_read_as_they_always_meant() {
|
||||
w.append("vendor.1099_set", p).unwrap();
|
||||
}
|
||||
drop(w);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
@@ -543,7 +605,7 @@ fn books_written_before_decisions_read_as_they_always_meant() {
|
||||
assert_eq!(store.journal_format().features, Vec::<String>::new());
|
||||
drop(store);
|
||||
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
let forms: Vec<(i64, Option<String>, bool)> = conn
|
||||
.prepare("SELECT id, form_1099, tin_on_file FROM vendors ORDER BY id")
|
||||
.unwrap()
|
||||
|
||||
@@ -20,8 +20,8 @@ use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
|
||||
use ledger_store::journal::{canonical_json, sha256_hex};
|
||||
use ledger_store::{
|
||||
DB_FILE, JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
|
||||
StoreError,
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
|
||||
StoreError, db_file,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
@@ -57,9 +57,11 @@ fn test_dir(name: &str) -> TestDir {
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// Books with one posted entry, closed again.
|
||||
/// Books with one posted entry, closed again: in journal format 1, as
|
||||
/// every version before the format gate wrote them (this version's own
|
||||
/// declare its format as they are created).
|
||||
fn books(dir: &Path) {
|
||||
let mut store = Store::create(dir, Currency::USD).unwrap();
|
||||
let mut store = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
@@ -177,12 +179,12 @@ fn old_reader_gets_newer_journal() {
|
||||
),
|
||||
],
|
||||
)[0];
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
|
||||
assert_newer(Store::open(&dir), seq, line_type, need, true);
|
||||
assert!(
|
||||
!dir.join(DB_FILE).exists(),
|
||||
!dir.join(db_file()).exists(),
|
||||
"a refused open rebuilt the database"
|
||||
);
|
||||
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
|
||||
@@ -746,7 +748,7 @@ fn a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was() {
|
||||
append_raw(dir, b"{\"seq\":");
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let had_db = dir.join(ledger_store::DB_FILE).exists();
|
||||
let had_db = dir.join(ledger_store::db_file()).exists();
|
||||
assert!(Store::open(dir).is_err(), "{}", dir.display());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "{}", dir.display());
|
||||
let set_aside: Vec<_> = std::fs::read_dir(dir)
|
||||
@@ -756,7 +758,7 @@ fn a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was() {
|
||||
.collect();
|
||||
assert!(set_aside.is_empty(), "{set_aside:?}");
|
||||
assert_eq!(
|
||||
dir.join(ledger_store::DB_FILE).exists(),
|
||||
dir.join(ledger_store::db_file()).exists(),
|
||||
had_db,
|
||||
"no database made for books that can't open"
|
||||
);
|
||||
@@ -885,16 +887,16 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
|
||||
}
|
||||
|
||||
/// P3's journal extensions (rules.md B-27; plan D-39): each is announced
|
||||
/// by a feature this version knows, declared once, just before the first
|
||||
/// line that needs it, with the features in force kept: `account_renamed`
|
||||
/// before the first `account.renamed`, `require_tax_category` before the
|
||||
/// first `settings.set` naming `policy.require_tax_category`. A reader
|
||||
/// whose features don't include it (every earlier version: none) stops at
|
||||
/// the declaration as newer, never at the line, and never calls it
|
||||
/// corrupt; books that never use either stay format 1 or 2 with no
|
||||
/// feature.
|
||||
/// by a feature this version knows, `account_renamed` for an
|
||||
/// `account.renamed`, `require_tax_category` for a `settings.set` naming
|
||||
/// `policy.require_tax_category`. Books an older version made (books():
|
||||
/// format 1, nothing declared) refuse both, journaling nothing, until the
|
||||
/// owner's format upgrade declares this version's whole format at once;
|
||||
/// then both are taken, under that one declaration. A reader whose
|
||||
/// features don't include them (every earlier version: none) stops at the
|
||||
/// declaration as newer, never at the line, and never calls it corrupt.
|
||||
#[test]
|
||||
fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
|
||||
fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
for f in ["account_renamed", "require_tax_category"] {
|
||||
assert!(KNOWN_FEATURES.contains(&f), "{f}");
|
||||
}
|
||||
@@ -902,26 +904,35 @@ fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
|
||||
books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let policy = || ledger_core::settings::Change {
|
||||
key: "policy.require_tax_category".into(),
|
||||
value: Some(json!("refuse")),
|
||||
};
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
let head = store.head();
|
||||
let waits = |r: Result<(), StoreError>, need: &str| match r {
|
||||
Err(StoreError::FormatUpgradePending { need: n }) => assert_eq!(n, need),
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
waits(
|
||||
store.rename_account(1, "Business Checking", None),
|
||||
"the journal feature account_renamed",
|
||||
);
|
||||
waits(
|
||||
store.change_settings(&[policy()], &source).map(|_| ()),
|
||||
"the journal feature require_tax_category",
|
||||
);
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
assert!(store.upgrade_format().unwrap().is_some());
|
||||
store.rename_account(1, "Business Checking", None).unwrap();
|
||||
store.rename_account(1, "Checking", None).unwrap();
|
||||
store
|
||||
.change_settings(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "policy.require_tax_category".into(),
|
||||
value: Some(json!("refuse")),
|
||||
}],
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
["account_renamed", "require_tax_category"]
|
||||
);
|
||||
store.change_settings(&[policy()], &source).unwrap();
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
drop(store);
|
||||
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let lines: Vec<serde_json::Value> = text
|
||||
@@ -933,23 +944,19 @@ fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
|
||||
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
|
||||
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
|
||||
.collect();
|
||||
let current = ledger_store::format::current();
|
||||
assert_eq!(kinds[0].0, FORMAT_SET);
|
||||
assert_eq!(
|
||||
kinds[0].1,
|
||||
json!({"format": 2, "features": ["account_renamed"]})
|
||||
json!({"format": current.format, "features": current.features})
|
||||
);
|
||||
assert_eq!(kinds[1].0, "account.renamed");
|
||||
assert_eq!(kinds[2].0, "account.renamed");
|
||||
assert_eq!(kinds[3].0, FORMAT_SET);
|
||||
assert_eq!(
|
||||
kinds[3].1,
|
||||
json!({"format": 2, "features": ["account_renamed", "require_tax_category"]})
|
||||
);
|
||||
assert_eq!(kinds[4].0, "settings.set");
|
||||
assert_eq!(kinds.len(), 5);
|
||||
assert_eq!(kinds[3].0, "settings.set");
|
||||
assert_eq!(kinds.len(), 4);
|
||||
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
|
||||
// so the gate stops it at the first declaration, before the rename:
|
||||
// the gate's refusal of a feature it doesn't know is pinned by
|
||||
// so the gate stops it at the declaration, before the rename: the
|
||||
// gate's refusal of a feature it doesn't know is pinned by
|
||||
// an_unknown_feature_before_a_rename_is_newer_at_the_declaration.
|
||||
// This version opens them, in the writer and the viewer.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
@@ -980,7 +987,7 @@ fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
|
||||
),
|
||||
],
|
||||
);
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(db_file()));
|
||||
match Store::open(&dir) {
|
||||
Err(StoreError::NewerJournal { seq, need, .. }) => {
|
||||
assert_eq!(seq, seqs[0]);
|
||||
@@ -1034,33 +1041,41 @@ fn lines(dir: &Path) -> Vec<serde_json::Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// P3 (defect 9; rules.md B-27): the first daily schedule declares journal
|
||||
/// feature schedule_convention, on the line before its schedule.registered,
|
||||
/// which carries convention "daily". A version before P3 knew no features,
|
||||
/// so it stops at that declaration (NewerJournal; format.rs's
|
||||
/// an_older_reader_stops_at_p3s_features), never reading the schedule as
|
||||
/// equal months. This version reads it back, posts by the day, and
|
||||
/// verifies; a second daily schedule declares nothing more.
|
||||
/// P3 (defect 9; rules.md B-27, D-39): a daily schedule's
|
||||
/// schedule.registered, which carries convention "daily", needs journal
|
||||
/// feature schedule_convention. Books an older version made (books():
|
||||
/// format 1) refuse one, journaling nothing, until the owner's format
|
||||
/// upgrade, whose one declaration is on the line before it. A version
|
||||
/// before P3 knew no features, so it stops at that declaration
|
||||
/// (NewerJournal; format.rs's an_older_reader_stops_at_p3s_features),
|
||||
/// never reading the schedule as equal months. This version reads it back,
|
||||
/// posts by the day, and verifies; a second daily schedule declares
|
||||
/// nothing more.
|
||||
#[test]
|
||||
fn the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there() {
|
||||
fn a_daily_schedule_waits_for_its_feature_and_an_older_reader_stops_there() {
|
||||
let dir = test_dir("daily-feature");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let id = store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 3, 15),
|
||||
ledger_core::ScheduleConvention::Daily,
|
||||
))
|
||||
.unwrap();
|
||||
let daily = |start| insurance(accounts, start, ledger_core::ScheduleConvention::Daily);
|
||||
let head = store.head();
|
||||
match store.register_schedule(daily(day(2026, 3, 15))) {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature schedule_convention")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
store.upgrade_format().unwrap();
|
||||
let id = store.register_schedule(daily(day(2026, 3, 15))).unwrap();
|
||||
let journal = lines(&dir);
|
||||
let n = journal.len();
|
||||
assert_eq!(journal[n - 1]["type"], "schedule.registered");
|
||||
assert_eq!(journal[n - 1]["payload"]["convention"], "daily");
|
||||
assert_eq!(journal[n - 2]["type"], FORMAT_SET);
|
||||
let current = ledger_store::format::current();
|
||||
assert_eq!(
|
||||
journal[n - 2]["payload"],
|
||||
json!({"format": 2, "features": ["schedule_convention"]})
|
||||
json!({"format": current.format, "features": current.features})
|
||||
);
|
||||
store.run_schedules(day(2026, 5, 31)).unwrap();
|
||||
store
|
||||
@@ -1152,7 +1167,7 @@ fn an_unknown_schedule_convention_is_newer_not_corrupt() {
|
||||
&dir,
|
||||
&[("schedule.registered", registered(json!("weekly")))],
|
||||
)[0];
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
for got in [Store::open(&dir).err(), Snapshot::load(&dir).err()] {
|
||||
match got {
|
||||
Some(StoreError::NewerJournal { seq: s, need, .. }) => {
|
||||
@@ -1166,20 +1181,21 @@ fn an_unknown_schedule_convention_is_newer_not_corrupt() {
|
||||
let accounts2 = schedule_books(&dir);
|
||||
assert_eq!(accounts2, accounts);
|
||||
append(&dir, &[("schedule.registered", registered(json!(7)))]);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(db_file())).unwrap();
|
||||
assert_corrupt(Store::open(&dir));
|
||||
}
|
||||
|
||||
/// P3 (R3; rules.md B-27): a schedule month reversed within its month is
|
||||
/// posted again only after journal feature month_reposting is declared
|
||||
/// posted again only in books that declare journal feature month_reposting
|
||||
/// (an older version would refuse the second schedule.posted as posted
|
||||
/// twice), and the books replay to the same ledger. A month reversed after
|
||||
/// it ended is left by the run, listed with why (it would count twice until
|
||||
/// the reversal), and declares nothing. Hand check: equal months of 100.00
|
||||
/// twice): books an older version made wait for the format upgrade, and
|
||||
/// the books replay to the same ledger. A month reversed after it ended is
|
||||
/// left by the run, listed with why (it would count twice until the
|
||||
/// reversal), and journals nothing. Hand check: equal months of 100.00
|
||||
/// from 2026-03-01; March posted, reversed on Mar 31, run again: one
|
||||
/// March, 100.00; April posted, reversed on May 2: April held.
|
||||
#[test]
|
||||
fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
|
||||
fn a_schedule_month_posted_again_waits_for_month_reposting_and_replays() {
|
||||
let dir = test_dir("schedule-repost");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
@@ -1194,12 +1210,22 @@ fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
|
||||
store
|
||||
.reverse_entry(mar, day(2026, 3, 31), "posted early")
|
||||
.unwrap();
|
||||
// Books an older version made (books(): format 1) wait for the
|
||||
// upgrade before March posts again; the owner's go-ahead declares it.
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let head = store.head();
|
||||
assert!(matches!(
|
||||
store.run_schedules(day(2026, 3, 31)),
|
||||
Err(StoreError::FormatUpgradePending { need }) if need == "the journal feature month_reposting"
|
||||
));
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
store.upgrade_format().unwrap();
|
||||
let posted = store.run_schedules(day(2026, 3, 31)).unwrap();
|
||||
assert_eq!(posted.len(), 1);
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
assert!(
|
||||
store
|
||||
.journal_format()
|
||||
.has_feature(ledger_store::format::MONTH_REPOSTING)
|
||||
);
|
||||
let apr = store.post_schedule_month(id, day(2026, 4, 30)).unwrap();
|
||||
store
|
||||
@@ -1228,18 +1254,19 @@ fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 review round 2 (the format-upgrade gate refuses a feature until the
|
||||
/// owner consents): every command that needs a journal feature decides it,
|
||||
/// and is refused, before its first journaled line, so a refused feature
|
||||
/// journals nothing: never a depreciation entry without the
|
||||
/// P3 review round 2, and the format-upgrade gate (D-39): in books an
|
||||
/// older version wrote, every command that needs a journal feature is
|
||||
/// refused (FormatUpgradePending) before its first journaled line, so the
|
||||
/// refusal journals nothing: never a depreciation entry without the
|
||||
/// `asset.depreciation_posted` that makes it the period's run, nor a
|
||||
/// disposal's final depreciation without its line. Each command: a period
|
||||
/// run again after its run was reversed (month_reposting), a disposal whose
|
||||
/// month's run was reversed (its final depreciation, month_reposting), a
|
||||
/// daily schedule (schedule_convention), and a schedule month posted again
|
||||
/// (month_reposting). Taken again, each journals and the books verify.
|
||||
/// (month_reposting). After the owner's upgrade each journals, and the
|
||||
/// books verify.
|
||||
#[test]
|
||||
fn a_refused_journal_feature_journals_nothing() {
|
||||
fn a_write_waiting_for_the_upgrade_journals_nothing() {
|
||||
let dir = test_dir("feature-refused");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
@@ -1292,12 +1319,15 @@ fn a_refused_journal_feature_journals_nothing() {
|
||||
store
|
||||
.reverse_entry(jan, day(2026, 1, 31), "doubted")
|
||||
.unwrap();
|
||||
// Books as an older version made them (format 1, books()): nothing
|
||||
// declared, and everything above needs nothing new.
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
|
||||
store.refuse_new_features(true);
|
||||
let head = store.head();
|
||||
let refused = |r: Result<(), StoreError>, what: &str| match r {
|
||||
Err(StoreError::FormatRefused(why)) => assert!(why.contains("feature"), "{what}: {why}"),
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert!(need.starts_with("the journal feature "), "{what}: {need}")
|
||||
}
|
||||
other => panic!("{what}: {other:?}"),
|
||||
};
|
||||
refused(
|
||||
@@ -1333,7 +1363,7 @@ fn a_refused_journal_feature_journals_nothing() {
|
||||
});
|
||||
store.verify().unwrap();
|
||||
|
||||
store.refuse_new_features(false);
|
||||
assert!(store.upgrade_format().unwrap().is_some());
|
||||
assert!(
|
||||
store
|
||||
.run_depreciation(asset, "2026-01")
|
||||
@@ -1347,9 +1377,6 @@ fn a_refused_journal_feature_journals_nothing() {
|
||||
store
|
||||
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
);
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
store.verify().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! The format upgrade (plan/served.md 3.9, D-39; rules.md B-27): books an
|
||||
//! older wanderbooks wrote open on their old journal format, refuse only
|
||||
//! what needs the new one, and move to this version's format in one
|
||||
//! journaled step; books this version makes are in its format from their
|
||||
//! first lines.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: no book is moved past what its owner's older version reads
|
||||
//! without the owner's go-ahead, and an upgrade changes nothing of the
|
||||
//! books but what may be written from then on.
|
||||
//! SOURCE: plan/served.md 3.9 ("Format changes are the server's gate, not
|
||||
//! the updater's"); D-39; rules.md B-27.
|
||||
//! INVARIANTS:
|
||||
//! 1. New books declare this version's format, every feature it knows,
|
||||
//! as they are created: they wait for no upgrade.
|
||||
//! 2. Older books open, in the writer and the viewer, without a byte of
|
||||
//! their journal changing, and say what upgrade they wait for.
|
||||
//! 3. Before the upgrade a write that needs the new format is refused,
|
||||
//! journaling nothing; a write that needs nothing new works.
|
||||
//! 4. The upgrade is one journal line declaring the whole format; asked
|
||||
//! again, it journals nothing.
|
||||
//! 5. Every book an earlier version wrote (tests/fixtures/old-books)
|
||||
//! upgrades, verifies as before and replays to exactly the figures
|
||||
//! that version computed, in the writer and the viewer.
|
||||
//! 6. Books declaring a feature this version doesn't know are still
|
||||
//! refused as newer: the upgrade never weakens the gate.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{FORMAT_SET, current};
|
||||
use ledger_store::{JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-format-upgrade-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn journal(dir: &Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
fn lines(dir: &Path) -> Vec<serde_json::Value> {
|
||||
String::from_utf8(journal(dir))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The payload this version's format is declared with.
|
||||
fn this_versions_declaration() -> serde_json::Value {
|
||||
let c = current();
|
||||
json!({"format": c.format, "features": c.features})
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
/// Books as an older version made them (journal format 1), with a chart
|
||||
/// and one posted entry.
|
||||
fn old_books(dir: &Path) -> Store {
|
||||
let mut s = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cap = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = s
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "Capital in")
|
||||
.unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(500)).unwrap();
|
||||
s.add_line(e, cap, Side::Credit, usd(500)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn a_setting() -> (Vec<Change>, Source) {
|
||||
(
|
||||
vec![Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Fernhill Labs LLC")),
|
||||
}],
|
||||
Source {
|
||||
decided_by: "Avery Quill".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// INVARIANT 1: new books are in this version's format from their second
|
||||
/// line on, and wait for no upgrade, in the writer and the viewer.
|
||||
#[test]
|
||||
fn new_books_are_in_this_versions_format_from_their_creation() {
|
||||
let dir = test_dir("new");
|
||||
let s = Store::create(&dir, Currency::USD).unwrap();
|
||||
assert_eq!(s.journal_format(), ¤t());
|
||||
assert_eq!(s.journal_format().upgrade(), None);
|
||||
drop(s);
|
||||
let l = lines(&dir);
|
||||
assert_eq!(l.len(), 2, "store.created, then the format");
|
||||
assert_eq!(l[0]["type"], "store.created");
|
||||
assert_eq!(l[1]["type"], FORMAT_SET);
|
||||
assert_eq!(l[1]["payload"], this_versions_declaration());
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.journal_format().upgrade(), None);
|
||||
s.verify().unwrap();
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(snap.journal_format(), ¤t());
|
||||
}
|
||||
|
||||
/// INVARIANTS 2 to 4: older books open without a byte changing and say
|
||||
/// what they wait for; a change that needs the new format is refused,
|
||||
/// journaling nothing, while one that needs nothing new is made; the
|
||||
/// upgrade is one line, the whole format at once, and asked again does
|
||||
/// nothing; then the change is made.
|
||||
#[test]
|
||||
fn a_change_needing_the_new_format_waits_for_the_upgrade() {
|
||||
let dir = test_dir("waits");
|
||||
drop(old_books(&dir));
|
||||
let before = journal(&dir);
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
assert_eq!(journal(&dir), before, "opening wrote nothing");
|
||||
let waiting = s.journal_format().upgrade().expect("they wait");
|
||||
assert_eq!(waiting.from, JournalFormat::default());
|
||||
assert_eq!(waiting.to, current());
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(snap.journal_format().upgrade(), Some(waiting.clone()));
|
||||
// Refused, saying why, with nothing journaled or applied.
|
||||
let (changes, source) = a_setting();
|
||||
match s.change_settings(&changes, &source) {
|
||||
Err(e @ StoreError::FormatUpgradePending { .. }) => {
|
||||
assert!(e.to_string().contains("format_upgrade_pending"), "{e}");
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert_eq!(journal(&dir), before);
|
||||
assert!(!s.ledger().settings().is_journaled());
|
||||
for refused in [
|
||||
s.set_convention(&ledger_core::conventions::NewConvention {
|
||||
id: "close-ask".into(),
|
||||
kind: ledger_core::conventions::ConventionKind::CloseStep,
|
||||
title: "Ask about cards".into(),
|
||||
body_md: "Ask.".into(),
|
||||
structured: None,
|
||||
source: source.clone(),
|
||||
})
|
||||
.map(drop),
|
||||
s.raise_open_item(&ledger_core::conventions::NewOpenItem {
|
||||
title: "Find the lease".into(),
|
||||
detail_md: None,
|
||||
due: None,
|
||||
source: source.clone(),
|
||||
})
|
||||
.map(drop),
|
||||
] {
|
||||
assert!(
|
||||
matches!(refused, Err(StoreError::FormatUpgradePending { .. })),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(journal(&dir), before);
|
||||
// What needs nothing new is made as ever.
|
||||
let supplies = s
|
||||
.add_account("6000", "Supplies", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let e = s
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(), "Pens")
|
||||
.unwrap();
|
||||
s.add_line(e, supplies, Side::Debit, usd(12)).unwrap();
|
||||
s.add_line(e, cash, Side::Credit, usd(12)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
assert_eq!(s.journal_format(), &JournalFormat::default());
|
||||
let figures = ledger_core::reports::snapshot(s.ledger()).unwrap();
|
||||
// The upgrade: one line, the whole format.
|
||||
let mid = journal(&dir);
|
||||
let n = lines(&dir).len();
|
||||
let up = s.upgrade_format().unwrap().expect("upgraded");
|
||||
assert_eq!(up, waiting);
|
||||
assert_eq!(s.journal_format(), ¤t());
|
||||
let after = lines(&dir);
|
||||
assert_eq!(after.len(), n + 1, "one line");
|
||||
assert_eq!(after[n]["type"], FORMAT_SET);
|
||||
assert_eq!(after[n]["payload"], this_versions_declaration());
|
||||
assert!(journal(&dir).starts_with(&mid), "nothing before it changed");
|
||||
assert_eq!(
|
||||
ledger_core::reports::snapshot(s.ledger()).unwrap(),
|
||||
figures,
|
||||
"the books read as before"
|
||||
);
|
||||
// Again: nothing.
|
||||
let upgraded = journal(&dir);
|
||||
assert_eq!(s.upgrade_format().unwrap(), None);
|
||||
assert_eq!(journal(&dir), upgraded);
|
||||
// Now the change is made.
|
||||
s.change_settings(&changes, &source).unwrap();
|
||||
assert!(s.ledger().settings().is_journaled());
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.journal_format(), ¤t());
|
||||
assert_eq!(s.journal_format().upgrade(), None);
|
||||
s.verify().unwrap();
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(snap.journal_format(), ¤t());
|
||||
assert_eq!(
|
||||
lines(&dir)
|
||||
.iter()
|
||||
.filter(|l| l["type"] == FORMAT_SET)
|
||||
.count(),
|
||||
1,
|
||||
"the format moved exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The books an earlier wanderbooks wrote (tests/fixtures/old-books; see
|
||||
/// tests/store.rs `old_books`).
|
||||
fn old_book_fixtures() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
assert!(dirs.len() >= 29, "{} old books", dirs.len());
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A fixture's journal and stored files in a fresh dir of their own.
|
||||
fn fixture_copy(fixture: &Path) -> TestDir {
|
||||
fn copy(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap() {
|
||||
let e = e.unwrap();
|
||||
if e.file_name() == "expected.json" || e.file_name() == "record.json" {
|
||||
continue;
|
||||
}
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
copy(&e.path(), &to.join(e.file_name()));
|
||||
} else {
|
||||
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = fixture.file_name().unwrap().to_string_lossy();
|
||||
let dir = test_dir(&format!("fixture-{name}"));
|
||||
copy(fixture, &dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// What expected.json records of a ledger (tests/store.rs
|
||||
/// `old_books_figures`, field for field).
|
||||
fn figures(l: &ledger_core::Ledger) -> serde_json::Value {
|
||||
let entries: Vec<_> = (1..l.next_entry_id())
|
||||
.filter_map(|id| l.get_entry(id))
|
||||
.collect();
|
||||
let parties: Vec<_> = (1..l.next_entry_id())
|
||||
.map(|id| (id, l.entry_parties(id)))
|
||||
.filter(|(_, p)| !p.is_empty())
|
||||
.collect();
|
||||
json!({
|
||||
"snapshot": ledger_core::reports::snapshot(l).map_err(|e| e.to_string()),
|
||||
"entries": entries,
|
||||
"assets": l.registered_assets().collect::<Vec<_>>(),
|
||||
"periods": l.list_periods(),
|
||||
"schedules": l.schedules().collect::<Vec<_>>(),
|
||||
"recurring": l.recurring_entries().collect::<Vec<_>>(),
|
||||
"invoices": l.invoices(),
|
||||
"bills": l.bills(),
|
||||
"payroll_runs": l.list_payroll_runs(),
|
||||
"entry_parties": parties,
|
||||
})
|
||||
}
|
||||
|
||||
/// INVARIANTS 2, 4 and 5: every book an earlier version wrote opens
|
||||
/// without a byte changing, upgrades in one line (or, already in this
|
||||
/// version's format, waits for nothing), verifies as it did, and replays
|
||||
/// to exactly the figures that version computed: in the writer, again
|
||||
/// after reopening, and in the viewer.
|
||||
#[test]
|
||||
fn every_book_an_earlier_version_wrote_upgrades_and_still_verifies() {
|
||||
let mut upgraded = 0;
|
||||
for fixture in old_book_fixtures() {
|
||||
let name = fixture.file_name().unwrap().to_string_lossy().to_string();
|
||||
let expected: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap();
|
||||
let dir = fixture_copy(&fixture);
|
||||
let before = journal(&dir);
|
||||
let mut s = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(journal(&dir), before, "{name}: opening wrote");
|
||||
let waiting = s.journal_format().upgrade();
|
||||
let declared = before
|
||||
.split(|b| *b == b'\n')
|
||||
.filter(|l| !l.is_empty())
|
||||
.any(|l| serde_json::from_slice::<serde_json::Value>(l).unwrap()["type"] == FORMAT_SET);
|
||||
if !declared {
|
||||
assert!(waiting.is_some(), "{name}: format 1 books wait");
|
||||
}
|
||||
let up = s.upgrade_format().unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(up, waiting, "{name}");
|
||||
let after = journal(&dir);
|
||||
assert!(after.starts_with(&before), "{name}: the old lines changed");
|
||||
let added = after[before.len()..]
|
||||
.split(|b| *b == b'\n')
|
||||
.filter(|l| !l.is_empty())
|
||||
.count();
|
||||
assert_eq!(added, usize::from(up.is_some()), "{name}: one line");
|
||||
if up.is_some() {
|
||||
upgraded += 1;
|
||||
}
|
||||
assert_eq!(s.journal_format(), ¤t(), "{name}");
|
||||
let verify = s.verify().map(|_| ()).map_err(|e| e.to_string());
|
||||
assert_eq!(json!(verify), expected["verify"], "{name}: verify");
|
||||
let mut got = figures(s.ledger());
|
||||
got["verify"] = expected["verify"].clone();
|
||||
assert_eq!(got, expected, "{name}: the writer's figures");
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: reopen: {e}"));
|
||||
assert_eq!(s.journal_format().upgrade(), None, "{name}");
|
||||
let mut got = figures(s.ledger());
|
||||
got["verify"] = expected["verify"].clone();
|
||||
assert_eq!(got, expected, "{name}: reopened");
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir).unwrap_or_else(|e| panic!("{name}: viewer: {e}"));
|
||||
assert_eq!(snap.journal_format(), ¤t(), "{name}");
|
||||
let mut got = figures(snap.ledger());
|
||||
got["verify"] = expected["verify"].clone();
|
||||
assert_eq!(got, expected, "{name}: the viewer's figures");
|
||||
}
|
||||
assert!(upgraded >= 28, "only {upgraded} fixtures were format 1");
|
||||
}
|
||||
|
||||
/// INVARIANT 6: books that declare a feature this version doesn't know
|
||||
/// (a newer version wrote them) are refused as newer, in the writer and
|
||||
/// the viewer, and never touched: there is nothing to upgrade.
|
||||
#[test]
|
||||
fn books_declaring_a_feature_this_version_doesnt_know_stay_refused_as_newer() {
|
||||
let dir = test_dir("newer");
|
||||
drop(old_books(&dir));
|
||||
{
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": ["from_a_newer_version"]}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append("account.added", json!({})).unwrap();
|
||||
}
|
||||
let before = journal(&dir);
|
||||
for refused in [Store::open(&dir).map(drop), Snapshot::load(&dir).map(drop)] {
|
||||
match refused {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features from_a_newer_version")
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(journal(&dir), before);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use ledger_core::{
|
||||
members_equity,
|
||||
};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
|
||||
use ledger_store::{JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -82,6 +82,25 @@ fn books(dir: &Path) -> Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`books`] as a version before the format gate made them: format 1,
|
||||
/// nothing declared (D-39).
|
||||
fn old_books(dir: &Path) -> Books {
|
||||
let mut store = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
let mut add = |code: &str, t| store.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add("1000", AccountType::Asset);
|
||||
let caps = [
|
||||
add("3000", AccountType::Equity),
|
||||
add("3001", AccountType::Equity),
|
||||
];
|
||||
let sales = add("4000", AccountType::Revenue);
|
||||
Books {
|
||||
store,
|
||||
cash,
|
||||
caps,
|
||||
sales,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`books`] opened again.
|
||||
fn reopen(dir: &Path) -> Books {
|
||||
let store = Store::open(dir).unwrap();
|
||||
@@ -147,11 +166,14 @@ fn three_cumulative_closes(dir: &Path) -> Books {
|
||||
b
|
||||
}
|
||||
|
||||
/// Contract (rules.md B-27; P3 defect 17): the first members close counted
|
||||
/// from its regime's start declares the `members_cumulative` feature, on
|
||||
/// the line just before it and once only, so an older version stops at the
|
||||
/// declaration (it would replay the close under the run rule). Books that
|
||||
/// never close to the members never declare it.
|
||||
/// Contract (rules.md B-27; P3 defect 17; D-39): a members close counted
|
||||
/// from its regime's start needs the `members_cumulative` feature, so an
|
||||
/// older version stops at its declaration (it would replay the close under
|
||||
/// the run rule). New books declare it on their second line, with the rest
|
||||
/// of this version's format, and never again; books an older version made
|
||||
/// (format 1) refuse a members close, journaling nothing, until the
|
||||
/// owner's format upgrade declares it, and close to retained earnings as
|
||||
/// ever.
|
||||
#[test]
|
||||
fn the_first_cumulative_close_declares_its_feature() {
|
||||
let dir = test_dir("declares");
|
||||
@@ -169,12 +191,13 @@ fn the_first_cumulative_close_declares_its_feature() {
|
||||
.filter(|(_, (t, p))| t == "journal.format_set" && declares(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let first_close = lines
|
||||
.iter()
|
||||
.position(|(t, p)| t == "period.closed" && p["close_to"]["rounding"] == "cumulative_since")
|
||||
.unwrap();
|
||||
assert_eq!(declarations, [first_close - 1]);
|
||||
assert_eq!(lines[first_close - 1].1["format"], 2);
|
||||
// Line 2: this version's whole format, as the books were created.
|
||||
assert_eq!(declarations, [1]);
|
||||
assert_eq!(lines[1].1["format"], 2);
|
||||
assert_eq!(
|
||||
lines[1].1["features"],
|
||||
json!(ledger_store::format::current().features)
|
||||
);
|
||||
assert_eq!(
|
||||
lines
|
||||
.iter()
|
||||
@@ -193,17 +216,47 @@ fn the_first_cumulative_close_declares_its_feature() {
|
||||
.count();
|
||||
assert_eq!(again, 1);
|
||||
|
||||
// Books that close only to retained earnings never declare it.
|
||||
let dir = test_dir("declares-not");
|
||||
let mut b = books(&dir);
|
||||
// Books an older version made: a members close waits for the upgrade,
|
||||
// a close to retained earnings doesn't.
|
||||
let dir = test_dir("declares-old");
|
||||
let mut b = old_books(&dir);
|
||||
earn_and_close(
|
||||
&mut b,
|
||||
1,
|
||||
"0.01",
|
||||
&CloseTo::RetainedEarnings { sweep: vec![] },
|
||||
);
|
||||
drop(b);
|
||||
let e = b.store.create_entry(d(2, 10), "sale").unwrap();
|
||||
let money = Money::new(dec("0.01"), Currency::USD);
|
||||
b.store.add_line(e, b.cash, Side::Debit, money).unwrap();
|
||||
b.store.add_line(e, b.sales, Side::Credit, money).unwrap();
|
||||
b.store.post_entry(e).unwrap();
|
||||
let p = b
|
||||
.store
|
||||
.create_period("2026-02", d(2, 1), month_end(2))
|
||||
.unwrap();
|
||||
let head = b.store.head();
|
||||
let to = CloseTo::Members(half(&b.caps));
|
||||
match b.store.close_period_to(p, &to) {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature members_cumulative")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert_eq!(b.store.head(), head, "refused whole");
|
||||
assert!(!journal(&dir).iter().any(|(_, p)| declares(p)));
|
||||
// The owner's go-ahead: the whole format at once, then the close.
|
||||
assert!(b.store.upgrade_format().unwrap().is_some());
|
||||
b.store.close_period_to(p, &to).unwrap().unwrap();
|
||||
drop(b);
|
||||
let lines = journal(&dir);
|
||||
let n = lines.len();
|
||||
assert_eq!(lines[n - 1].0, "period.closed");
|
||||
assert_eq!(lines[n - 2].0, "journal.format_set");
|
||||
assert_eq!(
|
||||
lines[n - 2].1["features"],
|
||||
json!(ledger_store::format::current().features)
|
||||
);
|
||||
}
|
||||
|
||||
/// A change to a journaled close's target.
|
||||
@@ -457,6 +510,18 @@ fn the_first_close_after_the_upgrade_trues_up_the_old_rules() {
|
||||
distributions: None,
|
||||
},
|
||||
];
|
||||
// Books an earlier version wrote: the close counted from its regime's
|
||||
// start waits for the owner's go-ahead (D-39), journaling nothing; the
|
||||
// upgrade declares this version's whole format.
|
||||
let head = store.head();
|
||||
match store.close_period_to(dec_period, &CloseTo::Members(members.clone())) {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature members_cumulative")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
assert!(store.upgrade_format().unwrap().is_some());
|
||||
let closing = store
|
||||
.close_period_to(dec_period, &CloseTo::Members(members))
|
||||
.unwrap()
|
||||
@@ -504,14 +569,18 @@ fn the_first_close_after_the_upgrade_trues_up_the_old_rules() {
|
||||
capital_on(store.ledger(), d(12, 31)),
|
||||
[dec("99.08"), dec("100.07")]
|
||||
);
|
||||
// One declaration, just before the close.
|
||||
// One declaration, the upgrade's, just before the close: this
|
||||
// version's whole format.
|
||||
drop(store);
|
||||
let lines = journal(&dir);
|
||||
let n = lines.len();
|
||||
assert_eq!(lines[n - 1].0, "period.closed");
|
||||
assert_eq!(lines[n - 1].1["close_to"]["since_seq"], 1);
|
||||
assert_eq!(lines[n - 2].0, "journal.format_set");
|
||||
assert_eq!(lines[n - 2].1["features"], json!(["members_cumulative"]));
|
||||
assert_eq!(
|
||||
lines[n - 2].1["features"],
|
||||
json!(ledger_store::format::current().features)
|
||||
);
|
||||
// Reopened, and in the viewer: the same.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ use ledger_core::{
|
||||
PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts,
|
||||
};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
|
||||
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, db_file};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -192,7 +192,7 @@ fn view(l: &Ledger) -> View {
|
||||
|
||||
fn delete_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! A party named the creditor of a liability (rules.md B-35, P3 defect
|
||||
//! 24), in the store: journaled as `party.creditor_set {id, account_id,
|
||||
//! on}`, after the journal declares the feature it needs
|
||||
//! (`party_creditors`) the first time, and never otherwise.
|
||||
//! on}`, which needs the journal feature `party_creditors`: books this
|
||||
//! version made declared it when they were created; books an older
|
||||
//! version wrote refuse it until their format upgrade (rules.md B-27).
|
||||
//!
|
||||
//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES`
|
||||
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled
|
||||
//! when the feature is in force; books that never use it never change),
|
||||
//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand.
|
||||
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (refused,
|
||||
//! journaling nothing, unless the books declared it; books that never use
|
||||
//! it never change), and `Ledger::set_party_creditor`'s SCENARIOS.
|
||||
//! Expected values by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS};
|
||||
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS, current};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store};
|
||||
use ledger_store::{
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, db_file,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -55,7 +59,13 @@ fn bytes(dir: &Path) -> Vec<u8> {
|
||||
/// Books with a card, the members' financing account, a member, and a
|
||||
/// posted entry.
|
||||
fn books(dir: &Path) -> (Store, u64, u64, u64, u64) {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
books_in(dir, ¤t())
|
||||
}
|
||||
|
||||
/// [`books`] in the journal format `format`: format 1
|
||||
/// ([`JournalFormat::default`]) is books an older version wrote.
|
||||
fn books_in(dir: &Path, format: &JournalFormat) -> (Store, u64, u64, u64, u64) {
|
||||
let mut s = Store::create_in_format(dir, Currency::USD, format).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
@@ -103,40 +113,48 @@ fn party_creditors_is_a_known_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS));
|
||||
}
|
||||
|
||||
/// Store::set_party_creditor doc: the first one declares the feature (one
|
||||
/// journal.format_set keeping the format, at least 2, and every feature
|
||||
/// already declared), then journals party.creditor_set; later ones journal
|
||||
/// one line each; the books replay to the same directory, through the
|
||||
/// store and the viewer's snapshot alike.
|
||||
/// Store::set_party_creditor doc: books this version made declared the
|
||||
/// feature when they were created (their second line, the only
|
||||
/// declaration), so each creditor named journals one line,
|
||||
/// party.creditor_set, and nothing else; the books replay to the same
|
||||
/// directory, through the store and the viewer's snapshot alike.
|
||||
#[test]
|
||||
fn naming_a_creditor_declares_its_feature_once() {
|
||||
fn naming_a_creditor_journals_one_line_each_time() {
|
||||
let dir = test_dir("declare");
|
||||
let (mut s, card, due, issuer, tobin) = books(&dir);
|
||||
let before = journal(&dir).len();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j[1].0, FORMAT_SET);
|
||||
assert!(
|
||||
j[1].1["features"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("party_creditors"))
|
||||
);
|
||||
let before = j.len();
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 2);
|
||||
assert_eq!(j[before].0, FORMAT_SET);
|
||||
assert_eq!(j.len(), before + 1);
|
||||
assert_eq!(
|
||||
j[before].1,
|
||||
json!({"format": 2, "features": ["party_creditors"]})
|
||||
);
|
||||
assert_eq!(
|
||||
j[before + 1],
|
||||
j[before],
|
||||
(
|
||||
"party.creditor_set".to_string(),
|
||||
json!({"id": issuer, "account_id": card, "on": true})
|
||||
)
|
||||
);
|
||||
assert_eq!(s.journal_format().features, vec!["party_creditors"]);
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
s.set_party_creditor(tobin, due, false).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 4, "the feature is declared once");
|
||||
assert_eq!(j.len(), before + 3, "one line each");
|
||||
assert_eq!(
|
||||
j[before + 3].1,
|
||||
j[before + 2].1,
|
||||
json!({"id": tobin, "account_id": due, "on": false})
|
||||
);
|
||||
assert_eq!(
|
||||
j.iter().filter(|(t, _)| t == FORMAT_SET).count(),
|
||||
1,
|
||||
"nothing declared on the way"
|
||||
);
|
||||
assert_eq!(s.journal_format(), ¤t());
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
let live = s.ledger().clone();
|
||||
s.verify().unwrap();
|
||||
@@ -148,15 +166,40 @@ fn naming_a_creditor_declares_its_feature_once() {
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert!(*snap.ledger() == live);
|
||||
assert_eq!(snap.journal_format().features, vec!["party_creditors"]);
|
||||
assert_eq!(snap.journal_format(), ¤t());
|
||||
// Rebuilt from the journal alone.
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(*s.ledger() == live);
|
||||
}
|
||||
|
||||
/// Books an older version wrote (format 1, declaring nothing) refuse a
|
||||
/// creditor while they wait for their format upgrade, journaling nothing,
|
||||
/// not the line and not the feature; once the owner upgrades them, the
|
||||
/// same command journals its one line.
|
||||
#[test]
|
||||
fn a_creditor_in_older_books_waits_for_the_format_upgrade() {
|
||||
let dir = test_dir("older");
|
||||
let (mut s, card, _, issuer, _) = books_in(&dir, &JournalFormat::default());
|
||||
let was = bytes(&dir);
|
||||
assert!(matches!(
|
||||
s.set_party_creditor(issuer, card, true),
|
||||
Err(StoreError::FormatUpgradePending { need }) if need.contains("party_creditors")
|
||||
));
|
||||
assert_eq!(bytes(&dir), was, "nothing journaled");
|
||||
assert!(s.ledger().creditors_of(card).is_empty());
|
||||
assert!(s.upgrade_format().unwrap().is_some());
|
||||
let before = journal(&dir).len();
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 1);
|
||||
assert_eq!(j[before].0, "party.creditor_set");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.ledger().creditors_of(card), vec![issuer]);
|
||||
}
|
||||
|
||||
/// A refusal journals nothing: not the line, and not the feature either.
|
||||
#[test]
|
||||
fn a_refused_creditor_journals_nothing() {
|
||||
@@ -164,34 +207,42 @@ fn a_refused_creditor_journals_nothing() {
|
||||
let (mut s, _, _, issuer, _) = books(&dir);
|
||||
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let was = bytes(&dir);
|
||||
let format = s.journal_format().clone();
|
||||
for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] {
|
||||
assert!(s.set_party_creditor(party, account, true).is_err());
|
||||
assert_eq!(bytes(&dir), was);
|
||||
}
|
||||
assert!(s.journal_format().features.is_empty());
|
||||
assert_eq!(s.journal_format(), &format);
|
||||
}
|
||||
|
||||
/// Books that never name a creditor never declare the feature: opening,
|
||||
/// verifying and rebuilding them leaves the journal byte-identical, in the
|
||||
/// format it was in.
|
||||
/// Books that never name a creditor are never changed by the feature:
|
||||
/// opening, verifying and rebuilding them leaves the journal
|
||||
/// byte-identical, in the format it was in, this version's or an older
|
||||
/// one's (which declares nothing and stays so).
|
||||
#[test]
|
||||
fn books_without_creditors_are_never_changed_by_the_feature() {
|
||||
let dir = test_dir("untouched");
|
||||
let (s, ..) = books(&dir);
|
||||
let format = s.journal_format().clone();
|
||||
drop(s);
|
||||
let was = bytes(&dir);
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.rebuild().unwrap();
|
||||
assert_eq!(s.journal_format(), &format);
|
||||
drop(s);
|
||||
assert_eq!(bytes(&dir), was);
|
||||
assert!(
|
||||
journal(&dir)
|
||||
.iter()
|
||||
.all(|(t, p)| t != FORMAT_SET || p["features"] == json!([]))
|
||||
);
|
||||
for (name, format) in [
|
||||
("untouched", current()),
|
||||
("untouched-old", JournalFormat::default()),
|
||||
] {
|
||||
let dir = test_dir(name);
|
||||
let (s, ..) = books_in(&dir, &format);
|
||||
assert_eq!(s.journal_format(), &format);
|
||||
drop(s);
|
||||
let was = bytes(&dir);
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.rebuild().unwrap();
|
||||
assert_eq!(s.journal_format(), &format, "{name}");
|
||||
drop(s);
|
||||
assert_eq!(bytes(&dir), was, "{name}");
|
||||
let j = journal(&dir);
|
||||
assert!(j.iter().all(|(t, _)| t != "party.creditor_set"), "{name}");
|
||||
assert!(
|
||||
j.iter().filter(|(t, _)| t == FORMAT_SET).count() <= 1,
|
||||
"{name}: nothing declared but at creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A line that can't replay is refused on open, with the database and
|
||||
@@ -224,7 +275,7 @@ fn corrupt_creditor_lines_are_refused_on_open() {
|
||||
drop(w);
|
||||
assert!(Store::open(&dir).is_err(), "{name}: {payload} opened");
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
assert!(
|
||||
Store::open(&dir).is_err(),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
|
||||
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, db_file};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -72,7 +72,7 @@ fn w9(last4: &str) -> W9Facts {
|
||||
|
||||
fn delete_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,14 @@ fn set(key: &str, value: serde_json::Value) -> Change {
|
||||
/// Books with accounts, a posted entry with its receipt, a filed document,
|
||||
/// and (with `settings`) settings of their own: what adoption must carry.
|
||||
fn source_books(dir: &Path, settings: bool) -> Store {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
// As an earlier version wrote them: journal format 1, or 2 once they
|
||||
// keep their own settings (this version's own books declare their
|
||||
// format as they are made).
|
||||
let format = ledger_store::JournalFormat {
|
||||
format: if settings { 2 } else { 1 },
|
||||
features: Vec::new(),
|
||||
};
|
||||
let mut s = Store::create_in_format(dir, Currency::USD, &format).unwrap();
|
||||
if settings {
|
||||
s.adopt_settings(&[set("company.name", json!("Adopted LLC"))], &src("Owner"))
|
||||
.unwrap();
|
||||
@@ -299,6 +306,9 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
|
||||
let source = source_books(&data, true);
|
||||
let source_head = source.head();
|
||||
let source_ledger = source.ledger().clone();
|
||||
// Format 2 with no feature, as P2 wrote them: this version knows
|
||||
// features they haven't declared, so they wait for the upgrade.
|
||||
assert!(source.journal_format().upgrade().is_some());
|
||||
drop(source);
|
||||
// A vault and a key beside them, as the old layout kept them.
|
||||
let vault = old.path().join("vault");
|
||||
@@ -322,9 +332,14 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.source_head, source_head);
|
||||
// Books with settings gain no event but the copy's move to this
|
||||
// version's format (new books of this version; the source keeps its
|
||||
// own): one journal.format_set.
|
||||
assert!(report.format_upgraded);
|
||||
assert_eq!(
|
||||
report.head, source_head,
|
||||
"books with settings gain no event"
|
||||
report.head.0,
|
||||
source_head.0 + 1,
|
||||
"books with settings gain only the format"
|
||||
);
|
||||
assert_eq!(report.blob_files, 2);
|
||||
assert_eq!(report.blobs_verified, 2);
|
||||
@@ -405,6 +420,9 @@ fn books_without_settings_need_them_and_get_them_on_the_way_in() {
|
||||
.unwrap();
|
||||
assert_eq!(report.source_head, head);
|
||||
assert_eq!(report.settings_imported, 1);
|
||||
// The copy is new books of this version: in its journal format (the
|
||||
// source, format 1, keeps its own).
|
||||
assert!(report.format_upgraded);
|
||||
// What was added: the journal format the settings need, declared
|
||||
// first (older books), then the settings.
|
||||
let journal = std::fs::read_to_string(root.book_dir(&id("acme")).join(JOURNAL_FILE)).unwrap();
|
||||
@@ -437,6 +455,7 @@ fn books_without_settings_need_them_and_get_them_on_the_way_in() {
|
||||
assert_eq!(added.last().map(String::as_str), Some("settings.set"));
|
||||
assert_eq!(report.head.0, head.0 + added.len() as u64);
|
||||
let adopted = Store::open(&root.book_dir(&id("acme"))).unwrap();
|
||||
assert_eq!(adopted.journal_format(), &ledger_store::format::current());
|
||||
assert!(adopted.ledger().settings().is_journaled());
|
||||
assert_eq!(
|
||||
adopted.ledger().settings().company("USD").name,
|
||||
@@ -881,6 +900,9 @@ fn settings_given_for_books_that_keep_their_own_are_reported_unused() {
|
||||
let old = tmp("adopt-ignored-src");
|
||||
let s = source_books(old.path(), true);
|
||||
let head = s.head();
|
||||
// Format 2 already: moved to this version's only if it knows features
|
||||
// these books haven't declared.
|
||||
let waits = s.journal_format().upgrade().is_some();
|
||||
drop(s);
|
||||
let report = root
|
||||
.adopt(
|
||||
@@ -893,7 +915,14 @@ fn settings_given_for_books_that_keep_their_own_are_reported_unused() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(report.settings_ignored);
|
||||
assert_eq!((report.head, report.settings_imported), (head, 0));
|
||||
assert_eq!(report.format_upgraded, waits);
|
||||
assert_eq!(
|
||||
(report.head.0, report.settings_imported),
|
||||
(head.0 + u64::from(waits), 0)
|
||||
);
|
||||
if !waits {
|
||||
assert_eq!(report.head, head);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Each projection schema in a file of its own (ledger_store::db_file_for;
|
||||
//! plan/served.md 3.9: before the owner consents to a format upgrade, the
|
||||
//! way back to an older version is always there; rules.md B-27).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an older version, gone back to, finds the books it can open,
|
||||
//! however far a newer one moved the projection.
|
||||
//! INVARIANTS:
|
||||
//! 1. This version opens or creates only its own schema's file (the
|
||||
//! legacy `wanderbooks.db` only when that already holds its schema);
|
||||
//! its own missing, it is built from the journal.
|
||||
//! 2. A file of any other schema, older or newer, is never migrated,
|
||||
//! written, refused or deleted: it is left byte for byte, and still
|
||||
//! whole for its own schema.
|
||||
//! 3. A damaged file of this version's own schema is kept aside and
|
||||
//! rebuilt, as ever.
|
||||
//! 4. Which file is in use, and which others are there, is said.
|
||||
//! ```
|
||||
//!
|
||||
//! The P2-shaped fixture (tests/fixtures/p2-books) is a books dir as P2
|
||||
//! left it: a format 1 journal and its projection in `wanderbooks.db`, at
|
||||
//! schema 12. While this version's schema is 12 too, it is used as it is;
|
||||
//! once a schema of P3's moves past it, it must be left byte for byte. A
|
||||
//! legacy file one schema older than this version's is made here too, so
|
||||
//! the second case runs on every version.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::schema::{MIGRATIONS, SCHEMA_VERSION};
|
||||
use ledger_store::{
|
||||
DiskCheck, JOURNAL_FILE, JournalFormat, LEGACY_DB_FILE, Snapshot, Store, db_file, db_file_for,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The schema P2 kept in `wanderbooks.db`, the last before each schema had
|
||||
/// a file of its own.
|
||||
const P2_SCHEMA: u32 = 12;
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-schema-files-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
/// Books with a chart and one posted entry (cash 500), closed again, in
|
||||
/// journal format `format`.
|
||||
fn books(dir: &Path, format: &JournalFormat) {
|
||||
let mut s = Store::create_in_format(dir, Currency::USD, format).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cap = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = s
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "Capital in")
|
||||
.unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(500)).unwrap();
|
||||
s.add_line(e, cap, Side::Credit, usd(500)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
/// The cash balance the books say.
|
||||
fn cash(s: &Store) -> Decimal {
|
||||
let id = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
s.ledger().account_balance(id).unwrap()
|
||||
}
|
||||
|
||||
/// A file's bytes, or None.
|
||||
fn bytes(path: &Path) -> Option<Vec<u8>> {
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
/// What a projection file says of itself, read without writing it: its
|
||||
/// schema, the journal lines it applied, and SQLite's integrity check.
|
||||
fn holds(path: &Path) -> (u32, u64, String) {
|
||||
let uri = format!("file:{}?mode=ro&immutable=1", path.display());
|
||||
let conn = rusqlite::Connection::open_with_flags(
|
||||
uri,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
|
||||
)
|
||||
.unwrap();
|
||||
let meta = |k: &str| -> String {
|
||||
conn.query_row("SELECT value FROM meta WHERE key=?1", [k], |r| r.get(0))
|
||||
.unwrap()
|
||||
};
|
||||
let check: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
(
|
||||
meta("schema_version").parse().unwrap(),
|
||||
meta("last_applied_seq").parse().unwrap(),
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
/// A copy of tests/fixtures/p2-books.
|
||||
fn p2_books() -> TestDir {
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-books");
|
||||
let dir = test_dir("p2");
|
||||
for name in [JOURNAL_FILE, LEGACY_DB_FILE] {
|
||||
std::fs::copy(from.join(name), dir.join(name)).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// How tests/fixtures/p2-books was written: by this code while its schema
|
||||
/// was P2's (12, and every line applied as P2 applies it), its projection
|
||||
/// then given P2's file name. Run with WB_WRITE_P2_FIXTURE=DIR.
|
||||
#[test]
|
||||
fn write_the_p2_fixture() {
|
||||
let Ok(out) = std::env::var("WB_WRITE_P2_FIXTURE") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
SCHEMA_VERSION, P2_SCHEMA,
|
||||
"only a P2-schema version writes it"
|
||||
);
|
||||
let out = PathBuf::from(out);
|
||||
books(&out, &JournalFormat::default());
|
||||
std::fs::rename(out.join(db_file()), out.join(LEGACY_DB_FILE)).unwrap();
|
||||
let _ = std::fs::remove_file(out.join(ledger_store::LOCK_FILE));
|
||||
let _ = std::fs::remove_dir(out.join("blobs"));
|
||||
}
|
||||
|
||||
/// INVARIANTS 1, 2 and 4, on a books dir exactly as P2 left it: with this
|
||||
/// version's schema P2's, its file is this version's and is used as it is
|
||||
/// (no rebuild); past it, the file is left byte for byte and whole for its
|
||||
/// schema, and this version builds its own from the journal. Either way the
|
||||
/// books open to the same figures and verify.
|
||||
#[test]
|
||||
fn a_p2_books_dirs_database_is_used_at_its_schema_and_left_alone_past_it() {
|
||||
let dir = p2_books();
|
||||
let legacy = dir.join(LEGACY_DB_FILE);
|
||||
let before = bytes(&legacy).unwrap();
|
||||
let lines = std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.count() as u64;
|
||||
assert_eq!(holds(&legacy), (P2_SCHEMA, lines, "ok".into()));
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(cash(&s), Decimal::from(500));
|
||||
s.verify().unwrap();
|
||||
if SCHEMA_VERSION == P2_SCHEMA {
|
||||
// Its schema: used as it is, nothing rebuilt or made beside it.
|
||||
assert_eq!(s.database_file(), LEGACY_DB_FILE);
|
||||
assert!(s.other_databases().is_empty());
|
||||
drop(s);
|
||||
assert!(!dir.join(db_file()).exists(), "no rebuild for nothing");
|
||||
} else {
|
||||
assert_eq!(s.database_file(), db_file());
|
||||
assert_eq!(s.other_databases(), [LEGACY_DB_FILE]);
|
||||
drop(s);
|
||||
assert_eq!(bytes(&legacy).unwrap(), before, "P2's file, byte for byte");
|
||||
assert_eq!(holds(&legacy), (P2_SCHEMA, lines, "ok".into()), "and whole");
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(cash(&s), Decimal::from(500));
|
||||
drop(s);
|
||||
assert_eq!(bytes(&legacy).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANTS 1, 2 and 4, on every version: a legacy file one schema older
|
||||
/// than this version's (as P2's is to P3's) is never migrated or written.
|
||||
/// It stays byte for byte, and whole for its own schema; this version's
|
||||
/// file is built from the journal and caught up as the books move on; the
|
||||
/// report names the one in use and the one left alone; the viewer checks
|
||||
/// the one in use.
|
||||
#[test]
|
||||
fn an_older_schemas_file_is_left_byte_for_byte() {
|
||||
let dir = test_dir("older");
|
||||
books(&dir, &JournalFormat::default());
|
||||
let own = dir.join(db_file());
|
||||
let legacy = dir.join(LEGACY_DB_FILE);
|
||||
// The older version's file: its schema, its own copy of the books.
|
||||
let older = SCHEMA_VERSION - 1;
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&legacy).unwrap();
|
||||
for m in &MIGRATIONS[..older as usize] {
|
||||
conn.execute_batch(m).unwrap();
|
||||
}
|
||||
for (k, v) in [
|
||||
("schema_version", older.to_string()),
|
||||
("base_currency", "USD".into()),
|
||||
("last_applied_seq", "0".into()),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
|
||||
[k, v.as_str()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(&own).unwrap();
|
||||
let before = bytes(&legacy).unwrap();
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.database_file(), db_file());
|
||||
assert_eq!(s.other_databases(), [LEGACY_DB_FILE]);
|
||||
assert_eq!(cash(&s), Decimal::from(500));
|
||||
let report = s.verify_and_heal().unwrap();
|
||||
assert_eq!(report.healed, None);
|
||||
assert_eq!(report.database, db_file());
|
||||
assert_eq!(report.other_databases, [LEGACY_DB_FILE]);
|
||||
// The books move on: this version's file follows, the other doesn't.
|
||||
s.add_account("6000", "Fees", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let head = s.head().0;
|
||||
drop(s);
|
||||
assert_eq!(bytes(&legacy).unwrap(), before, "never migrated or written");
|
||||
assert!(!dir.join(format!("{LEGACY_DB_FILE}-wal")).exists());
|
||||
assert_eq!(
|
||||
holds(&legacy),
|
||||
(older, 0, "ok".into()),
|
||||
"whole, at its schema"
|
||||
);
|
||||
assert_eq!(holds(&own), (SCHEMA_VERSION, head, "ok".into()));
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(snap.check_disk(), DiskCheck::Matches);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(bytes(&legacy).unwrap(), before);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a newer schema's file, whether under its own name or the
|
||||
/// legacy one, is left alone, never refused: the books open on this
|
||||
/// version's own file (built from the journal if it isn't there).
|
||||
#[test]
|
||||
fn a_newer_schemas_file_is_left_alone_not_refused() {
|
||||
let dir = test_dir("newer");
|
||||
books(&dir, &JournalFormat::default());
|
||||
let own = dir.join(db_file());
|
||||
// A newer version's files: its own name, and (as no version writes, but
|
||||
// one could find) the legacy name holding a newer schema.
|
||||
let newer = dir.join(db_file_for(SCHEMA_VERSION + 1));
|
||||
let legacy = dir.join(LEGACY_DB_FILE);
|
||||
for f in [&newer, &legacy] {
|
||||
std::fs::copy(&own, f).unwrap();
|
||||
let conn = rusqlite::Connection::open(f).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET value=?1 WHERE key='schema_version'",
|
||||
[(SCHEMA_VERSION + 1).to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("PRAGMA journal_mode=DELETE").unwrap();
|
||||
}
|
||||
let (newer_bytes, legacy_bytes) = (bytes(&newer).unwrap(), bytes(&legacy).unwrap());
|
||||
// This version's own file there, then not.
|
||||
for keep_own in [true, false] {
|
||||
if !keep_own {
|
||||
std::fs::remove_file(&own).unwrap();
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.database_file(), db_file(), "{keep_own}");
|
||||
let mut others = vec![LEGACY_DB_FILE.to_string(), db_file_for(SCHEMA_VERSION + 1)];
|
||||
others.sort();
|
||||
assert_eq!(s.other_databases(), others);
|
||||
assert_eq!(cash(&s), Decimal::from(500));
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(bytes(&newer).unwrap(), newer_bytes, "{keep_own}");
|
||||
assert_eq!(bytes(&legacy).unwrap(), legacy_bytes, "{keep_own}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: this version's own file, damaged (not a database, or
|
||||
/// holding another schema though named for this one), is kept aside and
|
||||
/// rebuilt from the journal; a damaged legacy file is another version's to
|
||||
/// mend, and left as it is.
|
||||
#[test]
|
||||
fn a_damaged_file_of_this_versions_schema_is_rebuilt() {
|
||||
let dir = test_dir("damaged");
|
||||
books(&dir, &JournalFormat::default());
|
||||
let own = dir.join(db_file());
|
||||
let kept_aside = |dir: &Path| {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt."))
|
||||
.count()
|
||||
};
|
||||
// Not a database at all.
|
||||
std::fs::write(&own, b"this is not a sqlite database at all").unwrap();
|
||||
for wal in ["-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{wal}", db_file())));
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(cash(&s), Decimal::from(500));
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(kept_aside(&dir), 1);
|
||||
assert_eq!(holds(&own).0, SCHEMA_VERSION);
|
||||
// Named for this schema, holding another.
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&own).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET value=?1 WHERE key='schema_version'",
|
||||
[(SCHEMA_VERSION + 1).to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(kept_aside(&dir), 2);
|
||||
assert_eq!(holds(&own).0, SCHEMA_VERSION);
|
||||
// A damaged legacy file: not this version's to mend.
|
||||
let legacy = dir.join(LEGACY_DB_FILE);
|
||||
std::fs::write(&legacy, b"garbage of an older version's").unwrap();
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.database_file(), db_file());
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(
|
||||
bytes(&legacy).unwrap(),
|
||||
b"garbage of an older version's".to_vec()
|
||||
);
|
||||
assert_eq!(kept_aside(&dir), 2);
|
||||
}
|
||||
|
||||
/// New books get this version's own schema's file, named for its schema,
|
||||
/// and nothing else.
|
||||
#[test]
|
||||
fn new_books_get_their_schemas_own_file() {
|
||||
let dir = test_dir("new");
|
||||
let s = Store::create(&dir, Currency::USD).unwrap();
|
||||
assert_eq!(s.database_file(), db_file());
|
||||
assert_eq!(db_file(), format!("wanderbooks.v{SCHEMA_VERSION}.db"));
|
||||
assert!(s.other_databases().is_empty());
|
||||
drop(s);
|
||||
assert!(dir.join(db_file()).exists());
|
||||
assert!(!dir.join(LEGACY_DB_FILE).exists());
|
||||
for (name, is) in [
|
||||
("wanderbooks.db", true),
|
||||
("wanderbooks.v13.db", true),
|
||||
("wanderbooks.v1.db", true),
|
||||
("wanderbooks.v.db", false),
|
||||
("wanderbooks.v13.db-wal", false),
|
||||
("wanderbooks.v13a.db", false),
|
||||
("wanderbooks.v13.corrupt.20261021T000000Z", false),
|
||||
("journal.jsonl", false),
|
||||
] {
|
||||
assert_eq!(ledger_store::is_database_file(name), is, "{name}");
|
||||
}
|
||||
}
|
||||
@@ -110,23 +110,26 @@ fn assert_every_reader_agrees(dir: &std::path::Path, s: &Store) {
|
||||
assert!(snap.ledger().differs_from(s.ledger()).is_empty());
|
||||
}
|
||||
|
||||
/// The first change declares journal format 2 before it (format gate), and
|
||||
/// only once; a change to what is already set journals nothing.
|
||||
/// Settings need journal format 2 (format gate): books this version made
|
||||
/// declared it as they were created, so the first change journals only
|
||||
/// itself; a change to what is already set journals nothing. Books an
|
||||
/// older version wrote in format 1 refuse the change, journaling nothing,
|
||||
/// until their format upgrade (the owner's go-ahead, D-39); then it is
|
||||
/// made, after the one declaration.
|
||||
#[test]
|
||||
fn the_first_change_declares_format_2() {
|
||||
fn a_change_needs_journal_format_2() {
|
||||
let (dir, mut s) = books("format");
|
||||
assert_eq!(s.journal_format().format, 1);
|
||||
assert_eq!(s.journal_format().format, 2);
|
||||
let before = s.head().0;
|
||||
s.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A"))
|
||||
.unwrap();
|
||||
assert_eq!(s.journal_format().format, 2);
|
||||
assert_eq!(s.head().0, before + 2, "a declaration, then the change");
|
||||
assert_eq!(s.head().0, before + 1, "the change alone");
|
||||
s.change_settings(
|
||||
&[set("company.timezone", json!("America/Denver"))],
|
||||
&src("A"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.head().0, before + 3, "declared once");
|
||||
assert_eq!(s.head().0, before + 2);
|
||||
let at = s.head().0;
|
||||
let made = s
|
||||
.change_settings(&[set("company.name", json!(" Acme LLC "))], &src("A"))
|
||||
@@ -134,7 +137,29 @@ fn the_first_change_declares_format_2() {
|
||||
assert!(made.is_empty());
|
||||
assert_eq!(s.head().0, at, "nothing to change, nothing journaled");
|
||||
let r = s.ledger().settings().get("company.name").unwrap();
|
||||
assert_eq!((r.seq, r.source.decided_by.as_str()), (before + 2, "A"));
|
||||
assert_eq!((r.seq, r.source.decided_by.as_str()), (before + 1, "A"));
|
||||
// Books in format 1, as an older version wrote them.
|
||||
let old = test_dir("format-old");
|
||||
let mut o =
|
||||
Store::create_in_format(&old, Currency::USD, &ledger_store::JournalFormat::default())
|
||||
.unwrap();
|
||||
let at = o.head().0;
|
||||
for refused in [
|
||||
o.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A")),
|
||||
o.adopt_settings(&[], &src("A")),
|
||||
] {
|
||||
assert!(
|
||||
matches!(refused, Err(StoreError::FormatUpgradePending { .. })),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(o.head().0, at, "refused: nothing journaled");
|
||||
assert!(!o.ledger().settings().is_journaled());
|
||||
o.upgrade_format().unwrap().expect("upgraded");
|
||||
o.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A"))
|
||||
.unwrap();
|
||||
assert_eq!(o.head().0, at + 2, "the declaration, then the change");
|
||||
assert_every_reader_agrees(&old, &o);
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.journal_format().format, 2);
|
||||
@@ -1070,19 +1095,19 @@ fn an_empty_import_still_moves_the_books_in() {
|
||||
let at = s.head().0;
|
||||
assert!(s.change_settings(&[], &src("A")).unwrap().is_empty());
|
||||
assert_eq!(s.head().0, at, "an ordinary empty change: nothing");
|
||||
assert_eq!(s.journal_format().format, 1);
|
||||
assert_eq!(s.journal_format().format, 2, "declared at creation");
|
||||
assert!(!s.ledger().settings().is_journaled());
|
||||
assert!(s.adopt_settings(&[], &src("A")).unwrap().is_empty());
|
||||
assert_eq!(s.head().0, at + 2, "a declaration, then the move");
|
||||
assert_eq!(s.head().0, at + 1, "the move alone");
|
||||
assert!(s.ledger().settings().is_journaled());
|
||||
assert_eq!(s.ledger().settings().last_seq(), Some(at + 2));
|
||||
assert_eq!(s.ledger().settings().last_seq(), Some(at + 1));
|
||||
for again in [
|
||||
s.adopt_settings(&[], &src("A")).unwrap(),
|
||||
s.change_settings(&[], &src("A")).unwrap(),
|
||||
] {
|
||||
assert!(again.is_empty());
|
||||
}
|
||||
assert_eq!(s.head().0, at + 2, "moved in once");
|
||||
assert_eq!(s.head().0, at + 1, "moved in once");
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(s.ledger().settings().is_journaled());
|
||||
|
||||
@@ -186,7 +186,7 @@ fn a_snapshot_checks_the_writers_database_and_files() {
|
||||
assert_eq!(snap.check_disk(), DiskCheck::Matches);
|
||||
assert_eq!(snap.verify_blobs(), (1, vec![]));
|
||||
// Someone edits the database behind the journal's back.
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
|
||||
[],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side};
|
||||
use ledger_store::journal::repair_torn_tail;
|
||||
use ledger_store::{BlobStore, DB_FILE, JOURNAL_FILE, Snapshot, Store, StoreError};
|
||||
use ledger_store::{BlobStore, JOURNAL_FILE, Snapshot, Store, StoreError, db_file};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -156,7 +156,7 @@ fn file_names(dir: &Path) -> Vec<String> {
|
||||
|
||||
fn remove_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,7 @@ fn open_rebuilds_a_missing_database() {
|
||||
drop(b);
|
||||
remove_db(&dir);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(dir.join(DB_FILE).exists());
|
||||
assert!(dir.join(db_file()).exists());
|
||||
assert_eq!(s.ledger(), &live);
|
||||
assert_eq!(
|
||||
s.events_since(0, 1_000_000).unwrap().len(),
|
||||
@@ -788,7 +788,7 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
|
||||
.take(8192)
|
||||
.copied()
|
||||
.collect();
|
||||
std::fs::write(dir.join(DB_FILE), &garbage).unwrap();
|
||||
std::fs::write(dir.join(db_file()), &garbage).unwrap();
|
||||
let journal_before = jbytes(&dir);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger(), &live);
|
||||
@@ -796,7 +796,7 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
|
||||
// Kept aside: some other file in the directory holds the garbage.
|
||||
let kept = tree(&dir)
|
||||
.into_iter()
|
||||
.any(|(p, bytes)| p != Path::new(DB_FILE) && bytes == garbage);
|
||||
.any(|(p, bytes)| p != Path::new(&db_file()) && bytes == garbage);
|
||||
assert!(
|
||||
kept,
|
||||
"corrupt db not kept aside; files: {:?}",
|
||||
@@ -815,8 +815,8 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
|
||||
fn open_catches_up_a_database_behind_the_journal() {
|
||||
let dir = test_dir("open-behind");
|
||||
drop(books(&dir));
|
||||
let old_db = std::fs::read(dir.join(DB_FILE)).unwrap();
|
||||
let old_wal = std::fs::read(dir.join(format!("{DB_FILE}-wal"))).ok();
|
||||
let old_db = std::fs::read(dir.join(db_file())).unwrap();
|
||||
let old_wal = std::fs::read(dir.join(format!("{}-wal", db_file()))).ok();
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.add_account("6300", "Meals", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
@@ -826,9 +826,9 @@ fn open_catches_up_a_database_behind_the_journal() {
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
remove_db(&dir);
|
||||
std::fs::write(dir.join(DB_FILE), &old_db).unwrap();
|
||||
std::fs::write(dir.join(db_file()), &old_db).unwrap();
|
||||
if let Some(w) = old_wal {
|
||||
std::fs::write(dir.join(format!("{DB_FILE}-wal")), w).unwrap();
|
||||
std::fs::write(dir.join(format!("{}-wal", db_file())), w).unwrap();
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger(), &live);
|
||||
@@ -1395,7 +1395,7 @@ fn verify_detects_projection_divergence() {
|
||||
.attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"d", None)
|
||||
.unwrap();
|
||||
{
|
||||
let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let c = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
let n = c.execute("DELETE FROM attachments", []).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
@@ -1448,7 +1448,7 @@ fn heal_rebuilds_a_diverged_projection() {
|
||||
.attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"h", None)
|
||||
.unwrap();
|
||||
{
|
||||
let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let c = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||
c.execute("DELETE FROM attachments", []).unwrap();
|
||||
}
|
||||
let journal = jbytes(&dir);
|
||||
|
||||
@@ -304,7 +304,7 @@ fn missing_database_heals_on_open() {
|
||||
let dir = test_dir("healopen");
|
||||
let store = build_books(&dir);
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(ledger_store::db_file())).unwrap();
|
||||
// No database at all: open rebuilds the projection from the journal.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
@@ -426,7 +426,7 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
||||
let _store = build_books(&dir);
|
||||
}
|
||||
// Corrupt the SQLite file with garbage.
|
||||
let db_path = dir.join(ledger_store::DB_FILE);
|
||||
let db_path = dir.join(ledger_store::db_file());
|
||||
std::fs::write(&db_path, b"this is not a sqlite database at all").unwrap();
|
||||
// Reopen heals from the journal instead of failing.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
@@ -435,9 +435,11 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
// Kept beside it under its own name: wanderbooks.v{N}.corrupt.*
|
||||
let stem = ledger_store::db_file().trim_end_matches(".db").to_string();
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("wanderbooks.corrupt.")
|
||||
.starts_with(&format!("{stem}.corrupt."))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(quarantined.len(), 1);
|
||||
@@ -838,11 +840,11 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
// Reopen (replay), then rebuild the projection from scratch.
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
std::fs::remove_file(dir.join(ledger_store::db_file())).unwrap();
|
||||
}
|
||||
let store = Store::open(&dir).unwrap();
|
||||
assert_eq!(report(store.ledger()), before);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
let tagged: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
|
||||
@@ -885,7 +887,7 @@ fn the_whole_projection_is_checked_and_healed() {
|
||||
store.verify().unwrap();
|
||||
drop(store);
|
||||
// Drift no accounting report would notice: a receipt quietly unlinked.
|
||||
let db = dir.join(ledger_store::DB_FILE);
|
||||
let db = dir.join(ledger_store::db_file());
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn.execute("UPDATE attachments SET entry_id = NULL", [])
|
||||
.unwrap();
|
||||
@@ -1781,7 +1783,7 @@ fn books_written_under_older_rules_still_open() {
|
||||
.unwrap();
|
||||
drop(w);
|
||||
// Opening rebuilds from the journal; it must accept all of it.
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let store = Store::open(&dir).expect("older books still open");
|
||||
store.verify().unwrap();
|
||||
assert_eq!(store.journal_format().format, 1);
|
||||
@@ -2570,12 +2572,12 @@ fn the_viewer_tells_behind_from_ahead() {
|
||||
// Behind: a database restored from before that write.
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let before_write = std::fs::read(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let before_write = std::fs::read(dir.join(ledger_store::db_file())).unwrap();
|
||||
store.add_vendor("Supplier").unwrap();
|
||||
drop(store);
|
||||
std::fs::write(dir.join(ledger_store::DB_FILE), &before_write).unwrap();
|
||||
let _ = std::fs::remove_file(dir.join("wanderbooks.db-wal"));
|
||||
let _ = std::fs::remove_file(dir.join("wanderbooks.db-shm"));
|
||||
std::fs::write(dir.join(ledger_store::db_file()), &before_write).unwrap();
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}-wal", ledger_store::db_file())));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}-shm", ledger_store::db_file())));
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
match snap.check_disk() {
|
||||
DiskCheck::Behind { applied, journal } => assert!(applied < journal),
|
||||
@@ -2590,7 +2592,7 @@ fn the_table_check_counts_missing_and_unexpected_rows() {
|
||||
let dir = test_dir("table-counts");
|
||||
let store = build_books(&dir);
|
||||
drop(store);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
// The events table mirrors the journal and isn't read back into the
|
||||
// ledger, so only the table check sees this.
|
||||
conn.execute(
|
||||
@@ -2636,7 +2638,7 @@ fn a_database_with_the_wrong_currency_is_rebuilt() {
|
||||
let store = build_books(&dir);
|
||||
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
|
||||
drop(store);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET value = 'EUR' WHERE key = 'base_currency'",
|
||||
[],
|
||||
@@ -2780,7 +2782,7 @@ fn the_database_tracks_what_an_invoice_has_been_paid() {
|
||||
)
|
||||
.unwrap();
|
||||
let row = |dir: &std::path::Path| -> (String, String) {
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
conn.query_row(
|
||||
"SELECT amount_paid, status FROM invoices WHERE id = ?",
|
||||
[inv as i64],
|
||||
@@ -2807,7 +2809,7 @@ fn rechain(dir: &std::path::Path, change: impl Fn(&mut Vec<ledger_store::Journal
|
||||
let mut lines = ledger_store::journal::read_and_verify_journal(&path).unwrap();
|
||||
change(&mut lines);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let mut w = JournalWriter::open(&path).unwrap();
|
||||
for l in lines {
|
||||
w.append(&l.line_type, l.payload).unwrap();
|
||||
@@ -2850,7 +2852,7 @@ fn replay_refuses_a_journaled_entry_it_does_not_rederive() {
|
||||
// A projection missing a table.
|
||||
let dir = test_dir("missing-table");
|
||||
drop(build_books(&dir));
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
conn.execute("DROP TABLE events", []).unwrap();
|
||||
drop(conn);
|
||||
let store = Store::open(&dir);
|
||||
@@ -2930,7 +2932,7 @@ fn a_database_with_damaged_pages_is_rebuilt() {
|
||||
}
|
||||
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
|
||||
drop(store);
|
||||
let db = dir.join(ledger_store::DB_FILE);
|
||||
let db = dir.join(ledger_store::db_file());
|
||||
let mut bytes = std::fs::read(&db).unwrap();
|
||||
// Scramble only the last pages (the customers just added); the header,
|
||||
// schema, and settings at the front still read.
|
||||
@@ -2967,7 +2969,7 @@ fn a_database_with_damaged_pages_is_rebuilt() {
|
||||
/// `meta`), as text, with `accounts.name` blanked: what a rename may
|
||||
/// change is that one column.
|
||||
fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec<String>)> {
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
let mut tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.unwrap()
|
||||
@@ -3065,28 +3067,29 @@ fn rename_account_changes_only_the_name_defect_15() {
|
||||
.rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21"))
|
||||
.unwrap();
|
||||
let after = std::fs::read_to_string(&journal).unwrap();
|
||||
// Every earlier line kept, byte for byte; two lines appended: the
|
||||
// feature's declaration, then the rename.
|
||||
// Every earlier line kept, byte for byte; one line appended, the
|
||||
// rename (its feature, account_renamed, the books declared as they
|
||||
// were created, with the rest of this version's format).
|
||||
assert!(after.starts_with(&text));
|
||||
assert!(
|
||||
store
|
||||
.journal_format()
|
||||
.has_feature(ledger_store::format::FEATURE_ACCOUNT_RENAMED)
|
||||
);
|
||||
let added: Vec<serde_json::Value> = after[text.len()..]
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
assert_eq!(added.len(), 2);
|
||||
assert_eq!(added[0]["type"], "journal.format_set");
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0]["type"], "account.renamed");
|
||||
assert_eq!(
|
||||
added[0]["payload"],
|
||||
serde_json::json!({"format": 2, "features": ["account_renamed"]})
|
||||
);
|
||||
assert_eq!(added[1]["type"], "account.renamed");
|
||||
assert_eq!(
|
||||
added[1]["payload"],
|
||||
serde_json::json!({
|
||||
"account_id": rent, "code": "6000", "name": "Office Rent",
|
||||
"previous": "Rent Expense", "reason": "owner asked 2026-10-21"
|
||||
})
|
||||
);
|
||||
assert_eq!(store.head().0, seq + 2);
|
||||
assert_eq!(store.head().0, seq + 1);
|
||||
assert_eq!(
|
||||
store.ledger().chart().get(rent).unwrap().name,
|
||||
"Office Rent"
|
||||
@@ -3096,7 +3099,7 @@ fn rename_account_changes_only_the_name_defect_15() {
|
||||
// The one column a rename changes, read from the projection itself
|
||||
// (projection_but_names blanks it).
|
||||
let db_name = |dir: &std::path::Path| -> String {
|
||||
rusqlite::Connection::open(dir.join(ledger_store::DB_FILE))
|
||||
rusqlite::Connection::open(dir.join(ledger_store::db_file()))
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT name FROM accounts WHERE id = ?",
|
||||
@@ -3120,7 +3123,7 @@ fn rename_account_changes_only_the_name_defect_15() {
|
||||
assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent");
|
||||
assert_eq!(figures_but_names(reopened.ledger()), figures);
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let rebuilt = Store::open(&dir).unwrap();
|
||||
assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent");
|
||||
assert_eq!(projection_but_names(&dir), rows);
|
||||
@@ -3159,7 +3162,7 @@ fn a_journaled_rename_replays_past_the_command_rules() {
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let store = Store::open(&dir);
|
||||
assert_eq!(store.is_ok(), opens, "{name:?}");
|
||||
let store = store.unwrap();
|
||||
@@ -3181,7 +3184,7 @@ fn a_journaled_rename_replays_past_the_command_rules() {
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
assert!(Store::open(&dir).is_err());
|
||||
}
|
||||
|
||||
@@ -3207,8 +3210,9 @@ fn tax_policy(store: &mut Store, value: Option<&str>) {
|
||||
/// expense account that isn't a header is refused without one, journaling
|
||||
/// nothing, and created with one as account.added then account.tax_set;
|
||||
/// a needed category can't be cleared; the accounts the engine creates
|
||||
/// for a control role are never refused. The first setting of the key
|
||||
/// declares the feature require_tax_category, once.
|
||||
/// for a control role are never refused. Setting the key needs the feature
|
||||
/// require_tax_category, which new books declared as they were created:
|
||||
/// the setting journals only itself.
|
||||
#[test]
|
||||
fn the_tax_category_policy_refuses_only_what_it_says() {
|
||||
let dir = test_dir("tax-policy");
|
||||
@@ -3224,12 +3228,8 @@ fn the_tax_category_policy_refuses_only_what_it_says() {
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
assert_eq!(new[0]["type"], "journal.format_set");
|
||||
assert_eq!(
|
||||
new[0]["payload"],
|
||||
serde_json::json!({"format": 2, "features": ["require_tax_category"]})
|
||||
);
|
||||
assert_eq!(new.last().unwrap()["type"], "settings.set");
|
||||
assert_eq!(new.len(), 1);
|
||||
assert_eq!(new[0]["type"], "settings.set");
|
||||
for (code, t, header, tax, refused) in [
|
||||
("6160", AccountType::Expense, false, None, true),
|
||||
("4100", AccountType::Revenue, false, None, true),
|
||||
@@ -3375,7 +3375,7 @@ fn a_journaled_clear_replays_under_refuse() {
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
assert!(
|
||||
@@ -3646,7 +3646,7 @@ fn books_written_under_p3_keep_their_names() {
|
||||
]
|
||||
);
|
||||
drop(store);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let rebuilt = Store::open(&dir).unwrap();
|
||||
assert_eq!(chart_of(rebuilt.ledger()), want);
|
||||
drop(rebuilt);
|
||||
@@ -3683,10 +3683,10 @@ fn a_new_accounts_name_is_journaled_trimmed() {
|
||||
assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting");
|
||||
let live = store.ledger().clone();
|
||||
drop(store);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let reopened = Store::open(&dir).unwrap();
|
||||
assert!(live.differs_from(reopened.ledger()).is_empty());
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| {
|
||||
r.get(0)
|
||||
|
||||
Reference in new issue
Block a user