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
@@ -0,0 +1,7 @@
|
||||
# The format upgrade
|
||||
|
||||
- A new version of wanderbooks can keep the books in a newer journal format than the version that wrote them. Books an older version wrote wait for a format upgrade: books.start says so (format_upgrade) and puts it first among its next steps, in every session until it is done.
|
||||
- Until then they work on their old format: everything that needs nothing new works as before, and whatever would need the new format is refused with format_upgrade_pending, changing nothing. Never retry it, work around it, or post it another way.
|
||||
- Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are.
|
||||
- Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record.
|
||||
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how
|
||||
//! the command line asks the books server to list, create, adopt and
|
||||
//! restore books, and to make, list and revoke agents' tokens.
|
||||
//! restore books, to make, list and revoke agents' tokens, and how the
|
||||
//! host hands it the owner's go-ahead for a format upgrade.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the command line makes and adopts books without ever touching
|
||||
@@ -26,6 +27,9 @@
|
||||
//! 6. A token's text is in exactly one answer, TokenCreated, to the
|
||||
//! request that made it; nothing else carries it (auth.rs
|
||||
//! INVARIANT 1).
|
||||
//! 7. A format upgrade is asked with the owner passphrase, which the
|
||||
//! server checks before anything is done (plan 3.9, D-39); no answer
|
||||
//! carries it, and Debug never shows it.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -140,6 +144,16 @@ pub enum Request {
|
||||
},
|
||||
/// The live tokens.
|
||||
TokenList,
|
||||
/// The owner's go-ahead for the format upgrade of books waiting for
|
||||
/// one (plan 3.9, D-39): a verified backup with the keys, then this
|
||||
/// version's journal format declared. The host sends it, for the
|
||||
/// lease socket's `consent_format_upgrade`.
|
||||
UpgradeFormat {
|
||||
/// The books.
|
||||
id: String,
|
||||
/// The owner passphrase, checked by the server (INVARIANT 7).
|
||||
passphrase: Passphrase,
|
||||
},
|
||||
/// End a token.
|
||||
TokenRevoke {
|
||||
/// Its books.
|
||||
@@ -163,6 +177,73 @@ impl std::fmt::Debug for TokenText {
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner passphrase in a request (and in the lease socket's consent,
|
||||
/// which the host hands on as this): Debug never shows it (INVARIANT 7),
|
||||
/// and it is wiped from memory when dropped, as the command line wipes
|
||||
/// what is typed.
|
||||
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Passphrase(pub String);
|
||||
|
||||
impl std::fmt::Debug for Passphrase {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Passphrase(…)")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Passphrase {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A format upgrade books wait for (plan 3.9, D-39): written by an older
|
||||
/// version, they open on their old format until the owner says go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormatWaiting {
|
||||
/// The journal format they are in.
|
||||
pub format: u32,
|
||||
/// The features they declared.
|
||||
pub features: Vec<String>,
|
||||
/// What the upgrade adds, in words ("journal format 2").
|
||||
pub adds: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&ledger_store::FormatUpgrade> for FormatWaiting {
|
||||
fn from(u: &ledger_store::FormatUpgrade) -> Self {
|
||||
Self {
|
||||
format: u.from.format,
|
||||
features: u.from.features.clone(),
|
||||
adds: u.adds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a format upgrade did.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormatUpgraded {
|
||||
/// The books.
|
||||
pub id: String,
|
||||
/// What it added, in words; empty when the books were already in this
|
||||
/// version's format (nothing was done).
|
||||
pub added: Vec<String>,
|
||||
/// The verified backup made first: the books as they were.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backup: Option<UpgradeBackup>,
|
||||
/// The books' head now.
|
||||
pub head: (u64, String),
|
||||
}
|
||||
|
||||
/// The backup a format upgrade made first, proven to hold the books as
|
||||
/// they were, their vault and keys too.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpgradeBackup {
|
||||
/// Its commit in the books' own backup repository.
|
||||
pub commit: String,
|
||||
/// The head it holds.
|
||||
pub head: (u64, String),
|
||||
}
|
||||
|
||||
/// A token, as listed (never its text).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TokenInfo {
|
||||
@@ -200,6 +281,17 @@ pub struct BookStatus {
|
||||
/// Why it couldn't be opened, if it couldn't.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub problem: Option<String>,
|
||||
/// The format upgrade it waits for, if it does.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format_upgrade: Option<FormatWaiting>,
|
||||
/// The projection database's file in use (a name, no folder), if it
|
||||
/// opened (ledger_store::db_file_for).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database: Option<String>,
|
||||
/// Other schemas' projection files beside it, left as they are for the
|
||||
/// versions that wrote them (never deleted).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub other_databases: Vec<String>,
|
||||
}
|
||||
|
||||
/// A setting an adoption would move in.
|
||||
@@ -259,6 +351,10 @@ pub struct Adopted {
|
||||
pub settings_ignored: bool,
|
||||
/// The source had no lock file, so one was made beside its journal.
|
||||
pub source_lock_created: bool,
|
||||
/// The copy was moved to this version's journal format (the source's
|
||||
/// is older, and keeps its own).
|
||||
#[serde(default)]
|
||||
pub format_upgraded: bool,
|
||||
/// Why the adopted books didn't open, if they didn't (they are kept;
|
||||
/// closing the books and opening them again tries again).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -359,6 +455,8 @@ pub enum Response {
|
||||
Tokens(Vec<TokenInfo>),
|
||||
/// A token ended.
|
||||
TokenRevoked(TokenInfo),
|
||||
/// A format upgrade made (or none needed).
|
||||
FormatUpgraded(FormatUpgraded),
|
||||
/// Refused, with why (no path in the data root: INVARIANT 3).
|
||||
Refused(String),
|
||||
}
|
||||
@@ -409,6 +507,9 @@ pub fn call_within(
|
||||
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
|
||||
Some("`wanderbooks token list` shows the tokens there are")
|
||||
}
|
||||
Request::UpgradeFormat { .. } => {
|
||||
Some("`wanderbooks books list` says whether the books still wait for it")
|
||||
}
|
||||
};
|
||||
let unknown = move |why: String| match look {
|
||||
None => CallError::Unknown(why),
|
||||
@@ -555,11 +656,37 @@ mod tests {
|
||||
#[test]
|
||||
fn answers_round_trip() {
|
||||
for r in [
|
||||
Response::Books(vec![BookStatus {
|
||||
id: "acme".into(),
|
||||
open: false,
|
||||
problem: Some("damaged".into()),
|
||||
}]),
|
||||
Response::Books(vec![
|
||||
BookStatus {
|
||||
id: "acme".into(),
|
||||
open: false,
|
||||
problem: Some("damaged".into()),
|
||||
format_upgrade: None,
|
||||
database: None,
|
||||
other_databases: Vec::new(),
|
||||
},
|
||||
BookStatus {
|
||||
id: "beta".into(),
|
||||
open: true,
|
||||
problem: None,
|
||||
format_upgrade: Some(FormatWaiting {
|
||||
format: 1,
|
||||
features: Vec::new(),
|
||||
adds: vec!["journal format 2".into()],
|
||||
}),
|
||||
database: Some("wanderbooks.v13.db".into()),
|
||||
other_databases: vec!["wanderbooks.db".into()],
|
||||
},
|
||||
]),
|
||||
Response::FormatUpgraded(FormatUpgraded {
|
||||
id: "beta".into(),
|
||||
added: vec!["journal format 2".into()],
|
||||
backup: Some(UpgradeBackup {
|
||||
commit: "c".repeat(40),
|
||||
head: (7, "ab".into()),
|
||||
}),
|
||||
head: (8, "cd".into()),
|
||||
}),
|
||||
Response::Created {
|
||||
id: "acme".into(),
|
||||
problem: None,
|
||||
@@ -598,6 +725,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 7: the go-ahead for a format upgrade carries the owner
|
||||
/// passphrase as written, and Debug never shows it; a book listed by
|
||||
/// an earlier server (no format_upgrade) reads as waiting for none.
|
||||
#[test]
|
||||
fn an_upgrade_request_carries_the_passphrase_and_never_shows_it() {
|
||||
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "upgrade_format",
|
||||
"id": "acme", "passphrase": "correct horse battery"}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.request,
|
||||
Request::UpgradeFormat {
|
||||
id: "acme".into(),
|
||||
passphrase: Passphrase("correct horse battery".into()),
|
||||
}
|
||||
);
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("horse"), "{shown}");
|
||||
assert!(shown.contains("Passphrase(…)"), "{shown}");
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(
|
||||
json!({"proto": 1, "op": "upgrade_format", "id": "acme"})
|
||||
)
|
||||
.is_err(),
|
||||
"an upgrade names the passphrase"
|
||||
);
|
||||
let listed: BookStatus =
|
||||
serde_json::from_value(json!({"id": "acme", "open": true})).unwrap();
|
||||
assert_eq!(listed.format_upgrade, None);
|
||||
let up = ledger_store::FormatUpgrade {
|
||||
from: ledger_store::JournalFormat::default(),
|
||||
to: ledger_store::format::current(),
|
||||
};
|
||||
let waiting = FormatWaiting::from(&up);
|
||||
assert_eq!((waiting.format, waiting.adds), (1, up.adds()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_requests_read_as_written() {
|
||||
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_create",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! and the owner (and agent) can see that it did, or why not.
|
||||
//! SOURCE: plan 3.7 (triggers, "ops/backup.jsonl", books.backup, Needs
|
||||
//! attention for a target more than 24 h behind, drills), D-30 (a
|
||||
//! git repository and a folder), D-31 (plain git).
|
||||
//! git repository and a folder), D-31 (plain git), 3.9 and D-39
|
||||
//! (a verified local backup with the keys before a format
|
||||
//! upgrade).
|
||||
//! INVARIANTS:
|
||||
//! 1. A backup is committed under the store's lock (ledger_store::backup
|
||||
//! INVARIANT 1; its stored files staged before, outside it) and
|
||||
@@ -41,6 +43,11 @@
|
||||
//! theirs): it is paused for them, and said, until another is named.
|
||||
//! 8. A damaged backup record never keeps the books shut: it is set
|
||||
//! aside and a new one begun.
|
||||
//! 9. A format upgrade (plan 3.9, D-39) is made only after the books are
|
||||
//! committed to their archive and the archive proven to hold them
|
||||
//! exactly as they are (journal, stored files, vault and keys), all
|
||||
//! under the store's lock, so nothing is written in between; a backup
|
||||
//! that can't be made or proven upgrades nothing. Both are records.
|
||||
//! ```
|
||||
|
||||
use crate::server::SharedStore;
|
||||
@@ -74,6 +81,76 @@ pub struct Backups {
|
||||
running: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
/// What a format upgrade did ([`Backups::upgrade_format`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upgraded {
|
||||
/// The upgrade; None when the books were already in this version's
|
||||
/// format (nothing was done).
|
||||
pub upgrade: Option<ledger_store::FormatUpgrade>,
|
||||
/// The verified backup made first (None when nothing was upgraded).
|
||||
pub backup: Option<UpgradeBackup>,
|
||||
/// The books' head now.
|
||||
pub head: (u64, String),
|
||||
}
|
||||
|
||||
/// The backup a format upgrade made first: the books as they were.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpgradeBackup {
|
||||
/// Whether it was committed now (false: the last backup already held
|
||||
/// the books as they were).
|
||||
pub made: bool,
|
||||
/// Its commit in the books' archive.
|
||||
pub commit: String,
|
||||
/// The head it holds: the books before the upgrade.
|
||||
pub head: (u64, String),
|
||||
}
|
||||
|
||||
/// Why a format upgrade wasn't made: nothing was upgraded.
|
||||
struct UpgradeFailed {
|
||||
/// The backup committed on the way, if one was.
|
||||
made: Option<backup::Made>,
|
||||
/// Why, in words.
|
||||
why: String,
|
||||
}
|
||||
|
||||
impl UpgradeFailed {
|
||||
/// The backup couldn't be made.
|
||||
fn backup(e: BackupError) -> Self {
|
||||
Self {
|
||||
made: None,
|
||||
why: format!(
|
||||
"the backup the upgrade makes first couldn't be made ({e}): nothing was upgraded"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup `made` (now, or earlier) didn't prove whole.
|
||||
fn proof(made: Option<backup::Made>, e: BackupError) -> Self {
|
||||
Self {
|
||||
made,
|
||||
why: format!(
|
||||
"the backup the upgrade makes first didn't prove whole ({e}): nothing was upgraded"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proven, and the books refused the upgrade.
|
||||
fn store(made: Option<backup::Made>, e: ledger_store::StoreError) -> Self {
|
||||
Self {
|
||||
made,
|
||||
why: format!("{e}: nothing was upgraded"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The books server stopped, or the books are closing.
|
||||
fn stopped(why: String) -> Self {
|
||||
Self {
|
||||
made: None,
|
||||
why: format!("{why}: nothing was upgraded"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One target's standing (INVARIANT 3).
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct TargetStatus {
|
||||
@@ -345,6 +422,129 @@ impl Backups {
|
||||
Ok(self.status().await)
|
||||
}
|
||||
|
||||
/// The format upgrade, after a verified local backup with the keys
|
||||
/// (plan/served.md 3.9, D-39; INVARIANT 9). The caller has the owner's
|
||||
/// go-ahead (the passphrase, checked by the server).
|
||||
///
|
||||
/// Under the store's lock, so nothing is written between the backup
|
||||
/// and the upgrade: the books committed to their archive (made
|
||||
/// whatever targets the settings name, or none), the archive cloned
|
||||
/// and proven to hold the books exactly as they are, journal, stored
|
||||
/// files, vault and keys ([`backup::prove_archive`]); only then this
|
||||
/// version's journal format declared (ledger_store
|
||||
/// `Store::upgrade_format`). Books already in it: nothing is done.
|
||||
/// Both the backup and the upgrade are records of `ops/backup.jsonl`;
|
||||
/// the targets take them at the next backup, the books having moved.
|
||||
pub async fn upgrade_format(&self) -> Result<Upgraded, String> {
|
||||
let _one = self.running.lock().await;
|
||||
let (store, dir, book) = (self.store.clone(), self.dir.clone(), self.book.clone());
|
||||
let done = tokio::task::spawn_blocking(move || {
|
||||
// Books that wait for nothing are left as they are: not even
|
||||
// their archive is made or staged (asked again under the lock
|
||||
// below; only an upgrade moves the format, one at a time).
|
||||
match store.with_store(|s| (s.journal_format().upgrade().is_none(), s.head())) {
|
||||
Ok((true, head)) => {
|
||||
return Ok(Upgraded {
|
||||
upgrade: None,
|
||||
backup: None,
|
||||
head,
|
||||
});
|
||||
}
|
||||
Ok((false, _)) => {}
|
||||
Err(why) => return Err(UpgradeFailed::stopped(why)),
|
||||
}
|
||||
// Stored files staged ahead, outside the lock (as a backup's).
|
||||
let git = Git::find();
|
||||
if let Ok(git) = &git {
|
||||
let _ = backup::stage_blobs(git, &dir);
|
||||
}
|
||||
store
|
||||
.with_store_mut(|s| -> Result<Upgraded, UpgradeFailed> {
|
||||
let head = s.head();
|
||||
let Some(waiting) = s.journal_format().upgrade() else {
|
||||
return Ok(Upgraded {
|
||||
upgrade: None,
|
||||
backup: None,
|
||||
head,
|
||||
});
|
||||
};
|
||||
let git = git.map_err(UpgradeFailed::backup)?;
|
||||
let made = backup::commit(&git, &dir, &book, head.clone())
|
||||
.map_err(UpgradeFailed::backup)?;
|
||||
let scratch = ledger_store::ops::ops_dir(&dir).join("upgrade-check");
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let proven = backup::prove_archive(&git, &dir, &head, &scratch)
|
||||
.and_then(|p| match &made {
|
||||
// The commit proven is the one just made.
|
||||
Some(m) if m.commit != p.commit => Err(BackupError::Damaged(
|
||||
"the backup it proved isn't the one it made".into(),
|
||||
)),
|
||||
_ => Ok(p),
|
||||
})
|
||||
.map_err(|e| UpgradeFailed::proof(made.clone(), e))?;
|
||||
let commit = proven.commit;
|
||||
let upgrade = match s.upgrade_format() {
|
||||
Ok(upgrade) => upgrade,
|
||||
// Journaled, and what follows the line failed (its
|
||||
// database row, a full disk): the books are in this
|
||||
// version's format all the same, and are never told
|
||||
// otherwise (the database catches up as the books
|
||||
// next open).
|
||||
Err(e) if s.journal_format().upgrade().is_none() => {
|
||||
eprintln!(
|
||||
"wanderbooks server: the format upgrade was journaled, then: {}",
|
||||
hide(&e.to_string(), &dir)
|
||||
);
|
||||
Some(waiting)
|
||||
}
|
||||
Err(e) => return Err(UpgradeFailed::store(made.clone(), e)),
|
||||
};
|
||||
Ok(Upgraded {
|
||||
upgrade,
|
||||
backup: Some(UpgradeBackup {
|
||||
made: made.is_some(),
|
||||
commit,
|
||||
head,
|
||||
}),
|
||||
head: s.head(),
|
||||
})
|
||||
})
|
||||
.map_err(UpgradeFailed::stopped)
|
||||
.and_then(|r| r)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(UpgradeFailed::stopped("the upgrade stopped".into())));
|
||||
match done {
|
||||
Ok(u) => {
|
||||
if let (Some(b), Some(up)) = (&u.backup, &u.upgrade) {
|
||||
if b.made {
|
||||
self.record(
|
||||
json!({"kind": "commit", "why": "format_upgrade", "ok": true,
|
||||
"commit": b.commit, "head": {"seq": b.head.0, "hash": b.head.1}}),
|
||||
);
|
||||
}
|
||||
self.record(json!({"kind": "format_upgrade", "ok": true,
|
||||
"backup": {"commit": b.commit, "head": {"seq": b.head.0, "hash": b.head.1}},
|
||||
"from": {"format": up.from.format, "features": up.from.features},
|
||||
"to": {"format": up.to.format, "features": up.to.features},
|
||||
"head": {"seq": u.head.0, "hash": u.head.1}}));
|
||||
}
|
||||
Ok(u)
|
||||
}
|
||||
Err(failed) => {
|
||||
if let Some(m) = &failed.made {
|
||||
self.record(
|
||||
json!({"kind": "commit", "why": "format_upgrade", "ok": true,
|
||||
"commit": m.commit, "head": {"seq": m.head.0, "hash": m.head.1}}),
|
||||
);
|
||||
}
|
||||
let why = hide(&failed.why, &self.dir);
|
||||
self.record(json!({"kind": "format_upgrade", "ok": false, "error": why}));
|
||||
Err(why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a drill is due (INVARIANT 5).
|
||||
pub async fn drill_due(&self) -> bool {
|
||||
let records = self.records();
|
||||
|
||||
@@ -83,6 +83,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
),
|
||||
("viewer", include_str!("../handbook/viewer.md")),
|
||||
("backups", include_str!("../handbook/backups.md")),
|
||||
(
|
||||
"format-upgrade",
|
||||
include_str!("../handbook/format-upgrade.md"),
|
||||
),
|
||||
(
|
||||
"rules-that-change",
|
||||
include_str!("../handbook/rules-that-change.md"),
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
//! The data dir holds `journal.jsonl` (the source of truth),
|
||||
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
|
||||
//! schema: ledger_store::db_file_for), `blobs/` (content-addressed receipts),
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
|
||||
@@ -136,6 +136,13 @@ enum Book {
|
||||
exports: ledger_store::exports::Exports,
|
||||
/// Its backups (backup.rs).
|
||||
backups: Arc<crate::backup::Backups>,
|
||||
/// The format upgrade it waits for, if any (plan 3.9, D-39): read
|
||||
/// from its journal as it opened, and cleared by the upgrade (only
|
||||
/// the upgrade moves an open book's format).
|
||||
waiting: Arc<Mutex<Option<ledger_store::FormatUpgrade>>>,
|
||||
/// Its projection database's file, and other schemas' beside it,
|
||||
/// by name, as it opened (`wanderbooks doctor` says them).
|
||||
database: (String, Vec<String>),
|
||||
},
|
||||
/// Not opened, with why (INVARIANT 3).
|
||||
Failed(String),
|
||||
@@ -289,6 +296,8 @@ impl Books {
|
||||
}
|
||||
let mut live = Live::own();
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
|
||||
let database = (store.database_file(), store.other_databases());
|
||||
let layout = Layout::Root(self.root.path().to_path_buf());
|
||||
let access = Arc::new(
|
||||
AccessLog::open(store.data_dir())
|
||||
@@ -347,6 +356,8 @@ impl Books {
|
||||
uploads,
|
||||
exports,
|
||||
backups,
|
||||
waiting,
|
||||
database,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -386,6 +397,22 @@ impl Books {
|
||||
Book::Open { .. } => None,
|
||||
Book::Failed(why) => Some(why.clone()),
|
||||
},
|
||||
format_upgrade: match book {
|
||||
Book::Open { waiting, .. } => waiting
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.map(admin::FormatWaiting::from),
|
||||
Book::Failed(_) => None,
|
||||
},
|
||||
database: match book {
|
||||
Book::Open { database, .. } => Some(database.0.clone()),
|
||||
Book::Failed(_) => None,
|
||||
},
|
||||
other_databases: match book {
|
||||
Book::Open { database, .. } => database.1.clone(),
|
||||
Book::Failed(_) => Vec::new(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
@@ -490,6 +517,7 @@ impl Books {
|
||||
confirmed,
|
||||
expect_head,
|
||||
} => self.restore(id, source, replace, confirmed, expect_head),
|
||||
Request::UpgradeFormat { id, passphrase } => self.upgrade_format(id, &passphrase),
|
||||
Request::BackUpNow { id, new_disk } => {
|
||||
let id = match BookId::parse(&id) {
|
||||
Ok(id) => id,
|
||||
@@ -655,6 +683,7 @@ impl Books {
|
||||
settings_imported: r.settings_imported,
|
||||
settings_ignored: r.settings_ignored,
|
||||
source_lock_created: r.source_lock_created,
|
||||
format_upgraded: r.format_upgraded,
|
||||
problem,
|
||||
})
|
||||
}
|
||||
@@ -666,6 +695,74 @@ impl Books {
|
||||
}
|
||||
|
||||
impl Books {
|
||||
/// The owner's go-ahead for a format upgrade (plan 3.9, D-39; admin
|
||||
/// INVARIANT 7): the owner passphrase checked (Argon2id, as the human
|
||||
/// pages check it) before anything is done; then a verified backup
|
||||
/// with the keys and the upgrade, under the books' lock
|
||||
/// (backup::Backups::upgrade_format). Books already in this version's
|
||||
/// format: nothing is done, and the answer says so.
|
||||
fn upgrade_format(&self, id: String, passphrase: &admin::Passphrase) -> Response {
|
||||
let id = match BookId::parse(&id) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return Response::Refused(self.hide(e.to_string())),
|
||||
};
|
||||
let (backups, waiting) = match self.held().get(id.as_str()) {
|
||||
Some(Book::Open {
|
||||
backups, waiting, ..
|
||||
}) => (backups.clone(), waiting.clone()),
|
||||
Some(Book::Failed(why)) => {
|
||||
return Response::Refused(format!("those books didn't open: {why}"));
|
||||
}
|
||||
None => {
|
||||
return Response::Refused(format!(
|
||||
"there are no books called {:?}: `wanderbooks books list` lists them",
|
||||
id.as_str()
|
||||
));
|
||||
}
|
||||
};
|
||||
let auth = match self.root.auth_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => return Response::Refused(self.hide(e.to_string())),
|
||||
};
|
||||
match ledger_store::passphrase::verify(&auth, &passphrase.0) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Response::Refused(
|
||||
"that isn't the owner passphrase: nothing was changed".into(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Response::Refused(format!(
|
||||
"{}: nothing was changed",
|
||||
self.hide(e.to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
match rt.block_on(backups.upgrade_format()) {
|
||||
Ok(done) => {
|
||||
// The books are in this version's format now (or were).
|
||||
*waiting.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
if let Some(up) = &done.upgrade {
|
||||
eprintln!(
|
||||
"wanderbooks server: the books {id} were upgraded to this version's journal format ({}), after a verified backup",
|
||||
up.adds().join(", ")
|
||||
);
|
||||
}
|
||||
Response::FormatUpgraded(admin::FormatUpgraded {
|
||||
id: id.to_string(),
|
||||
added: done.upgrade.map(|u| u.adds()).unwrap_or_default(),
|
||||
backup: done.backup.map(|b| admin::UpgradeBackup {
|
||||
commit: b.commit,
|
||||
head: b.head,
|
||||
}),
|
||||
head: done.head,
|
||||
})
|
||||
}
|
||||
Err(why) => Response::Refused(self.hide(why)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a backup (admin INVARIANT 4; ledger_store::root INVARIANT
|
||||
/// 7): a preview, or the books restored and opened. Books it replaces
|
||||
/// are let go first, and opened again if the restore fails.
|
||||
@@ -2976,4 +3073,318 @@ mod tests {
|
||||
);
|
||||
assert_eq!(bound(&books).len(), MAX_SESSIONS);
|
||||
}
|
||||
|
||||
/// The owner passphrase the upgrade tests set.
|
||||
const PASS: &str = "correct horse battery";
|
||||
|
||||
/// Books `id` as an older version wrote them (journal format 1), with
|
||||
/// a chart, an entry and a vault key, held by the server.
|
||||
fn old_books(books: &Books, id: &str) -> PathBuf {
|
||||
let id = BookId::parse(id).unwrap();
|
||||
let dir = books.root.book_dir(&id);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
||||
.unwrap();
|
||||
let mut s =
|
||||
Store::create_in_format(&dir, ledger_core::Currency::USD, &Default::default()).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cap = s
|
||||
.add_account(
|
||||
"3000",
|
||||
"Capital",
|
||||
ledger_core::AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s
|
||||
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "In")
|
||||
.unwrap();
|
||||
let m =
|
||||
ledger_core::Money::new(rust_decimal::Decimal::from(500), ledger_core::Currency::USD);
|
||||
s.add_line(e, cash, ledger_core::Side::Debit, m).unwrap();
|
||||
s.add_line(e, cap, ledger_core::Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
drop(s);
|
||||
let keys = dir.join(ledger_store::root::KEYS_DIR);
|
||||
std::fs::create_dir(&keys).unwrap();
|
||||
std::fs::write(
|
||||
keys.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
"ab".repeat(32),
|
||||
)
|
||||
.unwrap();
|
||||
books.hold(&id, books.open(&id));
|
||||
dir
|
||||
}
|
||||
|
||||
/// The books `id` as listed.
|
||||
fn listed(books: &Books, id: &str) -> admin::BookStatus {
|
||||
match books.admin(Request::List) {
|
||||
Response::Books(list) => list.into_iter().find(|b| b.id == id).unwrap(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the upgrade of `id` with `pass`, off the runtime (as the
|
||||
/// admin socket does).
|
||||
async fn upgrade(books: &Arc<Books>, id: &str, pass: &str) -> Response {
|
||||
let (books, id, pass) = (books.clone(), id.to_string(), pass.to_string());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
books.admin(Request::UpgradeFormat {
|
||||
id,
|
||||
passphrase: admin::Passphrase(pass),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// git, on the books' own archive: what it said.
|
||||
fn archive_git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg(format!("--git-dir={}", dir.join("archive.git").display()))
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
/// plan/served.md 3.9's `format_upgrade_waits_for_consent`, the
|
||||
/// server's part: books an older version wrote are served on their old
|
||||
/// format and listed as waiting; the go-ahead is refused without the
|
||||
/// owner passphrase (checked before anything is done: no backup, no
|
||||
/// line), and the books stay served as they were; with it, a backup of
|
||||
/// the books as they were, their keys in it, is committed and proven,
|
||||
/// then the whole format is declared in one line; asked again, nothing.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn format_upgrade_waits_for_consent() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
let dir = old_books(&books, "acme");
|
||||
let journal = || std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let before = journal();
|
||||
let waiting = listed(&books, "acme").format_upgrade.expect("it waits");
|
||||
// The database file it uses: this version's schema's own.
|
||||
assert_eq!(
|
||||
listed(&books, "acme").database.as_deref(),
|
||||
Some(ledger_store::db_file().as_str())
|
||||
);
|
||||
// Format 1 waits for this version's whole format: format 2 and
|
||||
// every feature it knows.
|
||||
let adds = ledger_store::JournalFormat::default()
|
||||
.upgrade()
|
||||
.unwrap()
|
||||
.adds();
|
||||
assert_eq!(adds[0], "journal format 2");
|
||||
assert_eq!(waiting.format, 1);
|
||||
assert_eq!(waiting.adds, adds);
|
||||
// No passphrase set yet, then a wrong one, then none: refused,
|
||||
// nothing done.
|
||||
match upgrade(&books, "acme", PASS).await {
|
||||
Response::Refused(why) => {
|
||||
assert!(why.contains("no owner passphrase is set yet"), "{why}");
|
||||
assert!(why.contains("nothing was changed"), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let auth = books.root.auth_dir().unwrap();
|
||||
ledger_store::passphrase::set(&auth, PASS, None).unwrap();
|
||||
for wrong in ["not the passphrase at all", ""] {
|
||||
match upgrade(&books, "acme", wrong).await {
|
||||
Response::Refused(why) => {
|
||||
assert_eq!(why, "that isn't the owner passphrase: nothing was changed")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(journal(), before);
|
||||
assert!(
|
||||
!dir.join("archive.git").exists(),
|
||||
"no backup before consent"
|
||||
);
|
||||
assert!(listed(&books, "acme").format_upgrade.is_some());
|
||||
assert_eq!(books.held()["acme"].problem(), None, "still served");
|
||||
// Books that don't exist, or didn't open.
|
||||
assert!(matches!(
|
||||
upgrade(&books, "nobody", PASS).await,
|
||||
Response::Refused(_)
|
||||
));
|
||||
// The go-ahead.
|
||||
let done = match upgrade(&books, "acme", PASS).await {
|
||||
Response::FormatUpgraded(u) => u,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(done.id, "acme");
|
||||
assert_eq!(done.added, adds);
|
||||
let backup = done.backup.clone().expect("backed up first");
|
||||
let after = journal();
|
||||
assert!(after.starts_with(&before));
|
||||
let added: Vec<serde_json::Value> = String::from_utf8(after[before.len()..].to_vec())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
assert_eq!(added.len(), 1, "one line");
|
||||
assert_eq!(added[0]["type"], "journal.format_set");
|
||||
let c = ledger_store::format::current();
|
||||
assert_eq!(
|
||||
added[0]["payload"],
|
||||
serde_json::json!({"format": c.format, "features": c.features})
|
||||
);
|
||||
assert_eq!(done.head.0, backup.head.0 + 1);
|
||||
// The backup: the books as they were, keys and all, the latest
|
||||
// commit of their archive.
|
||||
assert_eq!(
|
||||
archive_git(&dir, &["rev-parse", "HEAD"]).trim(),
|
||||
backup.commit
|
||||
);
|
||||
assert_eq!(
|
||||
archive_git(&dir, &["show", &format!("{}:journal.jsonl", backup.commit)]).as_bytes(),
|
||||
before.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
archive_git(
|
||||
&dir,
|
||||
&["show", &format!("{}:keys/legacy.key", backup.commit)]
|
||||
),
|
||||
"ab".repeat(32)
|
||||
);
|
||||
// Both on the backup record.
|
||||
let record =
|
||||
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
||||
.unwrap();
|
||||
assert!(record.contains(r#""kind":"format_upgrade""#), "{record}");
|
||||
assert!(record.contains(&backup.commit), "{record}");
|
||||
assert!(
|
||||
!ledger_store::ops::ops_dir(&dir)
|
||||
.join("upgrade-check")
|
||||
.exists()
|
||||
);
|
||||
// Waiting for nothing now; asked again, nothing is done.
|
||||
assert_eq!(listed(&books, "acme").format_upgrade, None);
|
||||
match upgrade(&books, "acme", PASS).await {
|
||||
Response::FormatUpgraded(u) => {
|
||||
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(journal(), after);
|
||||
// Books this version made wait for nothing, and asking for their
|
||||
// upgrade changes nothing of them: not even a backup repository.
|
||||
create(&books, "beta");
|
||||
assert_eq!(listed(&books, "beta").format_upgrade, None);
|
||||
let beta = books.root.book_dir(&BookId::parse("beta").unwrap());
|
||||
match upgrade(&books, "beta", PASS).await {
|
||||
Response::FormatUpgraded(u) => {
|
||||
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(!beta.join("archive.git").exists(), "nothing was changed");
|
||||
}
|
||||
|
||||
/// The backup is trusted only once proven, and only the one just made:
|
||||
/// an archive whose head names a branch other than the one a backup's
|
||||
/// clone takes (a damaged archive) takes the new commit where no
|
||||
/// clone finds it, so what is proven isn't what was made: nothing is
|
||||
/// upgraded, and it is recorded. Once the archive is right, its latest
|
||||
/// commit (which already holds the books) is proven and the upgrade
|
||||
/// made.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn no_upgrade_unless_the_backup_just_made_is_proven() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
let dir = old_books(&books, "acme");
|
||||
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
|
||||
let store = match &books.held()["acme"] {
|
||||
Book::Open { store, .. } => store.clone(),
|
||||
Book::Failed(why) => panic!("{why}"),
|
||||
};
|
||||
let (d, s) = (dir.clone(), store.clone());
|
||||
let first = tokio::task::spawn_blocking(move || {
|
||||
let git = ledger_store::backup::Git::find().unwrap();
|
||||
let head = s.with_store(|s| s.head()).unwrap();
|
||||
ledger_store::backup::commit(&git, &d, "acme", head).unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("a first backup");
|
||||
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/elsewhere"]);
|
||||
// A second later: the commit made now, of the same files, isn't
|
||||
// byte for byte the first (git dates commits to the second).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
match upgrade(&books, "acme", PASS).await {
|
||||
Response::Refused(why) => {
|
||||
assert!(why.contains("didn't prove whole"), "{why}");
|
||||
assert!(why.contains("nothing was upgraded"), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(listed(&books, "acme").format_upgrade.is_some());
|
||||
let record = || {
|
||||
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
record().contains(r#""why":"format_upgrade""#),
|
||||
"the commit made: {}",
|
||||
record()
|
||||
);
|
||||
assert!(record().contains(r#""ok":false"#), "{}", record());
|
||||
// The archive put right: its latest commit holds the books.
|
||||
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
|
||||
match upgrade(&books, "acme", PASS).await {
|
||||
Response::FormatUpgraded(u) => {
|
||||
let b = u.backup.clone().expect("proven");
|
||||
assert_eq!(b.commit, first.commit, "{u:?}");
|
||||
assert_eq!(b.head, first.head);
|
||||
assert_eq!(u.head.0, first.head.0 + 1);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(listed(&books, "acme").format_upgrade, None);
|
||||
}
|
||||
|
||||
/// A backup that can't be made upgrades nothing: the books stay on
|
||||
/// their old format, served and waiting, and the failure is recorded.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn no_upgrade_without_a_backup() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
let dir = old_books(&books, "acme");
|
||||
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
|
||||
// A file no book keeps: the backup refuses it.
|
||||
std::fs::write(dir.join(".gitattributes"), "* -text\n").unwrap();
|
||||
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
match upgrade(&books, "acme", PASS).await {
|
||||
Response::Refused(why) => {
|
||||
assert!(why.contains("couldn't be made"), "{why}");
|
||||
assert!(why.contains("nothing was upgraded"), "{why}");
|
||||
assert!(!why.contains(&home.path().display().to_string()), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(listed(&books, "acme").format_upgrade.is_some());
|
||||
let record =
|
||||
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
record.contains(r#""kind":"format_upgrade","ok":false"#)
|
||||
|| (record.contains(r#""kind":"format_upgrade""#)
|
||||
&& record.contains(r#""ok":false"#)),
|
||||
"{record}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,41 @@ impl SharedStore {
|
||||
Ok(f(store))
|
||||
}
|
||||
|
||||
/// Run `f` on the store under its lock, writing: the owner's format
|
||||
/// upgrade, after its backup (backup.rs). Blocking. Refused once the
|
||||
/// books are closing or the server stopped (INVARIANTS 2, 3), counted
|
||||
/// as a request while it runs, and one that panics stops the server
|
||||
/// (INVARIANT 1).
|
||||
pub fn with_store_mut<R>(
|
||||
self: &Arc<Self>,
|
||||
f: impl FnOnce(&mut Store) -> R,
|
||||
) -> Result<R, String> {
|
||||
if self.stopped() {
|
||||
return Err(STOPPED.to_string());
|
||||
}
|
||||
// Counted before the closing check, as a tool's request is.
|
||||
let _running = Running::new(self);
|
||||
if self.closing() {
|
||||
return Err("the books are closing".into());
|
||||
}
|
||||
let mut store = self.store.blocking_lock();
|
||||
if self.stopped() {
|
||||
return Err(STOPPED.to_string());
|
||||
}
|
||||
let store = store.as_mut().ok_or(STOPPED)?;
|
||||
/// Stops the server if `f` panics while it holds the store.
|
||||
struct StopOnPanic<'a>(&'a SharedStore);
|
||||
impl Drop for StopOnPanic<'_> {
|
||||
fn drop(&mut self) {
|
||||
if std::thread::panicking() {
|
||||
self.0.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _stop = StopOnPanic(self);
|
||||
Ok(f(store))
|
||||
}
|
||||
|
||||
/// Run `f` on the store if no one holds it now (books just opened):
|
||||
/// never waits, so never blocks a runtime's thread.
|
||||
pub fn try_with_store<R>(&self, f: impl FnOnce(&Store) -> R) -> Option<R> {
|
||||
@@ -3078,10 +3113,10 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A payment method exists only under the payment_instruments
|
||||
// feature (books.add_instrument declared it), so tagging the draft
|
||||
// with one below declares nothing after the draft's first line
|
||||
// (B-27).
|
||||
// A payment method exists only in books that declare the
|
||||
// payment_instruments feature (books.add_instrument needs it), so
|
||||
// tagging the draft with one below can't be refused for the format
|
||||
// after the draft's first line (B-27).
|
||||
let id = store
|
||||
.create_entry(date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -4301,8 +4336,10 @@ impl BooksServer {
|
||||
format!("journal hash chain ({} events)", report.events),
|
||||
"fresh replay matches the live ledger (every statement, assets, classes, payroll)"
|
||||
.to_string(),
|
||||
"SQLite database matches a fresh projection of the journal, every table, row for row"
|
||||
.to_string(),
|
||||
format!(
|
||||
"SQLite database ({}) matches a fresh projection of the journal, every table, row for row",
|
||||
report.database
|
||||
),
|
||||
];
|
||||
if let Some(n) = blobs {
|
||||
checked.push(format!("{n} stored files match their SHA-256"));
|
||||
@@ -4318,6 +4355,8 @@ impl BooksServer {
|
||||
head: report.head,
|
||||
healed: report.healed,
|
||||
blobs_verified: blobs,
|
||||
database: report.database,
|
||||
other_databases: report.other_databases,
|
||||
checked,
|
||||
}))
|
||||
}
|
||||
@@ -7169,10 +7208,10 @@ impl BooksServer {
|
||||
.set_party_creditor(id, *a, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// The creditor lines' journal feature, declared before this
|
||||
// command's first line, never between its lines (rules.md B-27: a
|
||||
// refused format upgrade must not leave the name changed and the
|
||||
// creditors not).
|
||||
// The creditor lines' journal feature, asked for before this
|
||||
// command's first line, never between its lines (rules.md B-27:
|
||||
// books waiting for their format upgrade must not be left with the
|
||||
// name changed and the creditors not).
|
||||
if !creditor.is_empty() {
|
||||
store
|
||||
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
|
||||
@@ -7473,9 +7512,9 @@ impl BooksServer {
|
||||
};
|
||||
let on_file = facts.is_some();
|
||||
// The vendor record's W-9 flag follows (below): its feature is
|
||||
// declared before this command's first line, never after it
|
||||
// (B-27: a refusal to upgrade the format must not leave the W-9
|
||||
// recorded and the flag not).
|
||||
// asked for before this command's first line, never after it
|
||||
// (B-27: books waiting for their format upgrade must not be left
|
||||
// with the W-9 recorded and the flag not).
|
||||
let vendor = store.ledger().get_party(id).and_then(|p| p.vendor);
|
||||
if vendor
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
@@ -10486,6 +10525,13 @@ mod tests {
|
||||
};
|
||||
out.push(("NewerJournal".into(), newer(true).to_string()));
|
||||
out.push(("NewerJournal unverified".into(), newer(false).to_string()));
|
||||
out.push((
|
||||
"FormatUpgradePending".into(),
|
||||
ledger_store::StoreError::FormatUpgradePending {
|
||||
need: "journal format 2".into(),
|
||||
}
|
||||
.to_string(),
|
||||
));
|
||||
out.push((
|
||||
"RuleNotKnown".into(),
|
||||
ledger_core::form1099::default_threshold(9999)
|
||||
@@ -11139,7 +11185,8 @@ mod tests {
|
||||
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
|
||||
rt.block_on(async {
|
||||
let seq: Body = |s| s.lock().map(|st| st.journal_seq());
|
||||
assert_eq!(run(&server, seq).await.unwrap(), Ok(1), "{how}");
|
||||
// New books: store.created, then their journal format.
|
||||
assert_eq!(run(&server, seq).await.unwrap(), Ok(2), "{how}");
|
||||
let died = run(&server, body).await.unwrap_err();
|
||||
assert_eq!(died.message, STOPPED, "{how}");
|
||||
// Every later request is refused, before it runs.
|
||||
|
||||
@@ -194,6 +194,19 @@ pub struct SectionOut {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// A format upgrade the books wait for.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FormatUpgradeOut {
|
||||
/// The guard: format_upgrade_pending (what a refused change says).
|
||||
pub guard: String,
|
||||
/// The journal format the books are in.
|
||||
pub format: u32,
|
||||
/// What the upgrade adds, in words.
|
||||
pub adds: Vec<String>,
|
||||
/// Who can give the go-ahead, and how.
|
||||
pub how: String,
|
||||
}
|
||||
|
||||
/// Something to do next.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NextStepOut {
|
||||
@@ -235,6 +248,12 @@ pub struct StartOut {
|
||||
pub truncated: Vec<String>,
|
||||
/// The handbook's sections (books.handbook {section}).
|
||||
pub handbook_sections: Vec<SectionOut>,
|
||||
/// The format upgrade these books wait for, if they do (plan 3.9,
|
||||
/// D-39): an older version wrote them, so they work on their old
|
||||
/// journal format, and whatever needs the new one is refused, until
|
||||
/// the owner gives the go-ahead. No tool can: tell the human.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format_upgrade: Option<FormatUpgradeOut>,
|
||||
/// What to do next, in order.
|
||||
pub next_steps: Vec<NextStepOut>,
|
||||
/// How the books are kept from agents: "same_user (advisory)", hidden
|
||||
@@ -778,6 +797,17 @@ impl BooksServer {
|
||||
why: why.into(),
|
||||
};
|
||||
let mut next_steps = Vec::new();
|
||||
// Books an older version wrote wait for the owner's go-ahead to
|
||||
// upgrade their format: first, at every start until it is given
|
||||
// (D-39). Only the owner can give it.
|
||||
let upgrade = store.journal_format().upgrade();
|
||||
if upgrade.is_some() {
|
||||
next_steps.push(step(
|
||||
"books.handbook",
|
||||
Some(serde_json::json!({"section": "format-upgrade"})),
|
||||
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
|
||||
));
|
||||
}
|
||||
// Books that read a settings file move it in before anything else
|
||||
// changes a setting (books.set_setting refuses until then).
|
||||
if !journaled {
|
||||
@@ -897,6 +927,12 @@ impl BooksServer {
|
||||
title: handbook::title(md).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
|
||||
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
|
||||
format: u.from.format,
|
||||
adds: u.adds(),
|
||||
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
|
||||
}),
|
||||
next_steps,
|
||||
isolation: "same_user (advisory)".to_string(),
|
||||
};
|
||||
@@ -968,7 +1004,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.start",
|
||||
title = "Start here",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, and next steps. Read-only; at most 8 KB.",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn start(
|
||||
|
||||
@@ -677,6 +677,14 @@ pub struct VerifyOut {
|
||||
pub healed: Option<String>,
|
||||
/// Stored files re-hashed, when checked.
|
||||
pub blobs_verified: Option<u64>,
|
||||
/// The books' database file verified (a file for each schema, named for
|
||||
/// it: "wanderbooks.v13.db", or the older "wanderbooks.db" when it holds
|
||||
/// this version's schema).
|
||||
pub database: String,
|
||||
/// Other schemas' database files beside it: left as they are for the
|
||||
/// versions that use them, never deleted.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub other_databases: Vec<String>,
|
||||
/// What was checked, in words.
|
||||
pub checked: Vec<String>,
|
||||
}
|
||||
@@ -4012,7 +4020,7 @@ pub struct ReconciliationsOut {
|
||||
/// Parameters for `books.handbook`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct HandbookParams {
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
|
||||
#[serde(default)]
|
||||
pub section: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1945,6 +1945,30 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
preview["lines"],
|
||||
json!(["Debit 4000 0.02", "Credit 3001 0.02"])
|
||||
);
|
||||
// Books an older version wrote: the close counted from its regime's
|
||||
// start waits for the owner's go-ahead (D-39), which no tool gives.
|
||||
let refused = mcp.call_err(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
refused.starts_with("format_upgrade_pending:") && refused.contains("members_cumulative"),
|
||||
"{refused}"
|
||||
);
|
||||
// The owner's go-ahead, as the app gives it (ledger-gui owner.rs, after
|
||||
// the passphrase and a verified backup): the books move to this
|
||||
// version's format.
|
||||
drop(mcp);
|
||||
ledger_store::Store::open(&books)
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("old books wait for the upgrade");
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&books,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
init(&mut mcp);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": true}),
|
||||
@@ -3124,11 +3148,9 @@ fn rename_account_end_to_end_defect_15() {
|
||||
);
|
||||
let after = mcp.call("books.verify", json!({"blobs": false}));
|
||||
assert_eq!(after["status"], "ok");
|
||||
assert_eq!(
|
||||
after["events"],
|
||||
json!(events + 2),
|
||||
"the feature and the rename"
|
||||
);
|
||||
// New books declare account_renamed (with the rest of this version's
|
||||
// format) as they are created: the rename journals only itself.
|
||||
assert_eq!(after["events"], json!(events + 1), "the rename");
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
let acct = chart["accounts"]
|
||||
.as_array()
|
||||
|
||||
@@ -356,7 +356,25 @@ const CHART: &[(&str, &str, &str)] = &[
|
||||
|
||||
/// Books with the chart; cash is 1000, financing 2100.
|
||||
fn books(name: &str) -> Mcp {
|
||||
books_in(name, false)
|
||||
}
|
||||
|
||||
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
|
||||
/// P3's features, books an older version wrote, waiting for their format
|
||||
/// upgrade (rules.md B-27).
|
||||
fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
features: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
@@ -365,6 +383,23 @@ fn books(name: &str) -> Mcp {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
|
||||
/// after the passphrase and a verified backup; no tool can): the
|
||||
/// server stopped, the books moved to this version's format, and
|
||||
/// served again.
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("P2's books wait for the upgrade");
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
/// The same books, served again (a restart).
|
||||
fn restart(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
@@ -553,7 +588,11 @@ fn set_vendor_1099_says_what_a_decision_needs() {
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
assert_eq!(m.count("vendor.1099_set"), 0);
|
||||
assert_eq!(m.count("journal.format_set"), 0, "nothing declared either");
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
1,
|
||||
"nothing declared either: only the books' own format, from their creation"
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
|
||||
@@ -646,30 +685,37 @@ fn a_members_method_off_the_financing_accounts_warns() {
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
}
|
||||
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 on books that
|
||||
/// never used the vendor_1099_decisions feature journals the W-9 and then
|
||||
/// the vendor's W-9 flag, a line of that feature, so the feature is
|
||||
/// declared before the command's first line, never between its lines
|
||||
/// (where a refused upgrade would leave the W-9 recorded and the flag
|
||||
/// not).
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 journals the W-9
|
||||
/// and then the vendor's W-9 flag, a line of the vendor_1099_decisions
|
||||
/// feature, so it asks for the feature before the command's first line,
|
||||
/// never between its lines. In books waiting for their format upgrade
|
||||
/// (P2's) the whole command is refused, journaling nothing (never the W-9
|
||||
/// recorded and the flag not); once the owner upgrades them, as in books
|
||||
/// this version made, it journals the W-9, then the flag, and declares
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn set_w9_declares_the_w9_flags_feature_before_its_first_line() {
|
||||
let mut m = books("set-w9-feature");
|
||||
fn set_w9_asks_for_the_w9_flags_feature_before_its_first_line() {
|
||||
let w9 = json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"});
|
||||
let mut m = books_in("set-w9-feature", true);
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let before = m.journal_types().len();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
let why = m.refuse("books.set_w9", w9.clone());
|
||||
assert!(
|
||||
why.starts_with("format_upgrade_pending:") && why.contains("vendor_1099_decisions"),
|
||||
"{why}"
|
||||
);
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(
|
||||
added,
|
||||
["journal.format_set", "party.w9_set", "vendor.w9_flag_set"],
|
||||
"the declaration comes first"
|
||||
);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
assert_eq!(m.count("party.w9_set"), 0, "nothing half done");
|
||||
let mut fresh = books("set-w9-feature-new");
|
||||
fresh.add_party("Harbor Design LLC", &["contractor"]);
|
||||
fresh.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
for mut m in [m.upgraded(), fresh] {
|
||||
let before = m.journal_types().len();
|
||||
m.ok("books.set_w9", w9.clone());
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(added, ["party.w9_set", "vendor.w9_flag_set"]);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
|
||||
|
||||
@@ -340,7 +340,7 @@ impl Mcp {
|
||||
walk(&p, out);
|
||||
} else if name.ends_with("-wal") || name.ends_with("-shm") {
|
||||
continue;
|
||||
} else if name == ledger_store::DB_FILE {
|
||||
} else if ledger_store::is_database_file(&name) {
|
||||
out.insert(p.clone(), db_digest(&p));
|
||||
} else {
|
||||
let bytes = std::fs::read(&p).unwrap_or_default();
|
||||
|
||||
@@ -361,7 +361,25 @@ const CHART: &[(&str, &str, &str)] = &[
|
||||
/// financing 2100; the chart; both members and the card's issuer in the
|
||||
/// directory.
|
||||
fn books(name: &str) -> Mcp {
|
||||
books_in(name, false)
|
||||
}
|
||||
|
||||
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
|
||||
/// P3's features, books an older version wrote, waiting for their format
|
||||
/// upgrade (rules.md B-27).
|
||||
fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
features: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let toml = books_toml(
|
||||
&dir,
|
||||
"Fernhill Labs LLC",
|
||||
@@ -378,6 +396,25 @@ fn books(name: &str) -> Mcp {
|
||||
m
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
|
||||
/// after the passphrase and a verified backup; no tool can): the
|
||||
/// server stopped, the books moved to this version's format, and
|
||||
/// served again.
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("P2's books wait for the upgrade");
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// A party's part of an entry as books.get_entry gives it: (resolved,
|
||||
/// direction, cash, in_kind).
|
||||
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
|
||||
@@ -560,7 +597,8 @@ fn defect14_party_totals_take_a_basis() {
|
||||
|
||||
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
|
||||
/// liabilities the company owes a party itself on (journaled as
|
||||
/// party.creditor_set, the journal's feature declared first), replaces
|
||||
/// party.creditor_set, under the journal feature the books declared when
|
||||
/// they were created, so nothing is declared on the way), replaces
|
||||
/// the list, refuses what isn't a liability with nothing journaled; and a
|
||||
/// fee on the issuer's own card then reads neither, so the issuer is paid
|
||||
/// what the card payment paid, once.
|
||||
@@ -595,8 +633,8 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
assert_eq!(m.count("party.creditor_set"), 1);
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
before + 1,
|
||||
"the feature declared once"
|
||||
before,
|
||||
"nothing declared on the way"
|
||||
);
|
||||
// The same list again changes nothing.
|
||||
let journal = m.journal();
|
||||
@@ -633,31 +671,40 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!([]));
|
||||
assert_eq!(m.count("party.creditor_set"), 4);
|
||||
assert_eq!(m.count("journal.format_set"), before + 1);
|
||||
assert_eq!(m.count("journal.format_set"), before);
|
||||
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
|
||||
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// rules.md B-27: `books.update_party` declares the `party_creditors`
|
||||
/// feature before the first line it writes, never between them: a name
|
||||
/// change with the first creditor is journaled as the declaration, then
|
||||
/// the name, then the creditor (a refused format upgrade then leaves
|
||||
/// nothing half done).
|
||||
/// rules.md B-27: `books.update_party` asks for the `party_creditors`
|
||||
/// feature before the first line it writes, never between them. In books
|
||||
/// waiting for their format upgrade (P2's), a name change with the first
|
||||
/// creditor is refused whole, journaling nothing (never the notes changed
|
||||
/// and the creditor not); once the owner upgrades them, as in books this
|
||||
/// version made, it journals the change, then the creditor, and declares
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn update_party_declares_the_creditor_feature_before_its_first_line() {
|
||||
let mut m = books("feature-first");
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]}),
|
||||
fn update_party_asks_for_the_creditor_feature_before_its_first_line() {
|
||||
let change =
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]});
|
||||
let mut m = books_in("feature-first", true);
|
||||
let why = m.refuse("books.update_party", change.clone());
|
||||
assert!(
|
||||
why.starts_with("format_upgrade_pending:") && why.contains("party_creditors"),
|
||||
"{why}"
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["journal.format_set", "party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
assert_eq!(m.count("party.updated"), 0, "nothing half done");
|
||||
for mut m in [m.upgraded(), books("feature-first-new")] {
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok("books.update_party", change.clone());
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
|
||||
@@ -1378,8 +1378,8 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
);
|
||||
seen.push(f.file_name().unwrap().to_string_lossy().into_owned());
|
||||
}
|
||||
for must in ["journal.jsonl", "wanderbooks.db"] {
|
||||
assert!(seen.iter().any(|s| s == must), "{must} scanned: {seen:?}");
|
||||
for must in ["journal.jsonl".to_string(), ledger_store::db_file()] {
|
||||
assert!(seen.contains(&must), "{must} scanned: {seen:?}");
|
||||
}
|
||||
// The vault holds them encrypted only.
|
||||
let vault_files = files_under(&m.vault());
|
||||
|
||||
Reference in new issue
Block a user