feat(store): draft states and discards are journaled, posted_seq is kept (P4)
Accounting reason: a draft that waits on its receipt, and one thrown
away, are facts about the books' working papers that must survive a
restart and replay identically, or a discarded draft would come back and
hold its period open. entry.draft_state_set and entry.draft_discarded are
drafts_v2's (an older version stops at the declaration instead of
misreading them); no figure moves. Schema 19 (taken at merge as the next
after the current last): entries.posted_seq, and a draft_states table
(drafts are not rows of entries, so the plan's entries.draft_state column
has nowhere to live). posted_seq is the journal line that posted each
entry (entry.posted, entry.reversed, the line that closed a period or
posted a schedule month), identical live and on replay.
8.0.6: a hand-written line of each new shape in
books_written_under_older_rules_still_open; fixture old-books/p4_before
written by 25b52580 (hand entries posted late, a reversal, a document, a
schedule month, a recurring draft, open drafts, a close) opens to the
same figures (serialized entries omit a building state, so no golden
moved); drafts_v2_is_its_own_feature; the fuzzer and GUI journal know the
types.
Tests: draft_states_replay_and_project, drafts_v2_waits_for_the_upgrade,
posted_seq_is_the_posting_lines_seq, an_older_version_stops_at_the_draft_events.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
be8bcf340e
commit
28d46332e4
18 files changed
+1455
-24
No files matched your search
@@ -64,6 +64,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
DOCUMENT_STANDING,
|
||||
DOCUMENTS_V2,
|
||||
ATTACHMENT_TEXT,
|
||||
DRAFTS_V2,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -208,6 +209,15 @@ pub const DOCUMENTS_V2: &str = "documents_v2";
|
||||
/// declaration instead (rules.md B-27, B-44).
|
||||
pub const ATTACHMENT_TEXT: &str = "attachment_text";
|
||||
|
||||
/// Feature: `drafts_v2` (plan P4, D-52): `entry.draft_state_set` (a draft
|
||||
/// building or awaiting its evidence) and `entry.draft_discarded` (a
|
||||
/// draft thrown away, with why). An older version doesn't know those
|
||||
/// event types: it would refuse them as unknown, or, replaying around
|
||||
/// them, keep a discarded draft that blocks a period's close and call a
|
||||
/// waiting draft one being built; it stops at the declaration instead
|
||||
/// (rules.md B-27, B-43).
|
||||
pub const DRAFTS_V2: &str = "drafts_v2";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -243,6 +253,8 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"document.filed",
|
||||
"entry.approved",
|
||||
"entry.created",
|
||||
"entry.draft_discarded",
|
||||
"entry.draft_state_set",
|
||||
"entry.instrument_set",
|
||||
"entry.line_added",
|
||||
"entry.line_class_set",
|
||||
@@ -942,6 +954,41 @@ mod tests {
|
||||
assert_eq!(up.adds(), vec!["the journal feature documents_v2"]);
|
||||
}
|
||||
|
||||
/// The version before P4 (every feature through `attachment_text`)
|
||||
/// stops at a declaration of `drafts_v2` as newer, never keeping a
|
||||
/// discarded draft or calling a waiting one a draft being built; and
|
||||
/// books that declared every earlier feature still wait for it
|
||||
/// (B-27, D-39).
|
||||
#[test]
|
||||
fn drafts_v2_is_its_own_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&DRAFTS_V2));
|
||||
let before: Vec<&str> = KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != DRAFTS_V2)
|
||||
.collect();
|
||||
let l = line(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [ATTACHMENT_TEXT, DRAFTS_V2]}),
|
||||
);
|
||||
match declared_knowing(&l, &before) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features drafts_v2")
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
let older = JournalFormat {
|
||||
format: 2,
|
||||
features: before.iter().map(|f| f.to_string()).collect(),
|
||||
};
|
||||
assert!(matches!(
|
||||
older.require_feature(DRAFTS_V2),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
let up = older.upgrade().expect("waits for it");
|
||||
assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]);
|
||||
}
|
||||
|
||||
/// This version's format: the newest it reads, every feature it
|
||||
/// knows, each once, sorted (what new books and an upgrade declare).
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,7 @@ use ledger_core::arap::{SendKind, Sent, SentVia};
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{
|
||||
Annotation, AnnotationFields, ClassId, ProjectFacts, ProjectKind, ProjectStatus,
|
||||
Annotation, AnnotationFields, ClassId, DraftState, ProjectFacts, ProjectKind, ProjectStatus,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -624,6 +624,44 @@ pub fn entry_settles_of(p: &Value) -> Result<(u64, Vec<u64>), StoreError> {
|
||||
Ok((u64_of(p, E, "entry_id")?, settles))
|
||||
}
|
||||
|
||||
/// `entry.draft_state_set {id, state, note?}` (plan P4): where a draft
|
||||
/// stands. A state is a note on the draft; it moves no figure.
|
||||
pub fn draft_state_set(id: u64, state: &DraftState) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
o.insert("state".into(), json!(state.key()));
|
||||
if !state.note().is_empty() {
|
||||
o.insert("note".into(), json!(state.note()));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `entry.draft_state_set`: `(draft, state)`. A state this
|
||||
/// version doesn't know is newer, never corrupt (rules.md B-27).
|
||||
pub fn draft_state_set_of(line: &JournalLine) -> Result<(u64, DraftState), StoreError> {
|
||||
const E: &str = "entry.draft_state_set";
|
||||
let p = &line.payload;
|
||||
let key = str_of(p, E, "state")?;
|
||||
let note = opt_str(p, E, "note")?.unwrap_or_default();
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
DraftState::parse(&key, ¬e).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the draft state {key:?}"))
|
||||
})?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `entry.draft_discarded {id, reason}` (plan P4): a draft thrown away.
|
||||
pub fn draft_discarded(id: u64, reason: &str) -> Value {
|
||||
json!({"id": id, "reason": reason})
|
||||
}
|
||||
|
||||
/// Read an `entry.draft_discarded`: `(draft, reason)`.
|
||||
pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> {
|
||||
const E: &str = "entry.draft_discarded";
|
||||
Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 18;
|
||||
pub const SCHEMA_VERSION: u32 = 19;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -468,5 +468,22 @@ CREATE TABLE IF NOT EXISTS annotations(
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
ALTER TABLE attachments ADD COLUMN duplicate_of INTEGER NULL;
|
||||
"#,
|
||||
r#"
|
||||
-- v19: draft states and posting order (plan P4, `drafts_v2`). Drafts are
|
||||
-- not rows of `entries` (they never touch the projection until posted),
|
||||
-- so a draft's state has a table of its own: one row per draft awaiting
|
||||
-- its evidence (a draft being built is the default and has no row), gone
|
||||
-- when the draft posts or is discarded. `posted_seq` is the journal line
|
||||
-- (`entry.posted` or `entry.reversed`) that posted the entry: what the
|
||||
-- digest's `since_seq` reads. Old rows are never migrated in place: the
|
||||
-- database file is named for its schema, so a v19 file is built from the
|
||||
-- journal.
|
||||
ALTER TABLE entries ADD COLUMN posted_seq INTEGER NULL;
|
||||
CREATE TABLE IF NOT EXISTS draft_states(
|
||||
entry_id INTEGER PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
note TEXT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -1261,7 +1261,15 @@ impl Store {
|
||||
line_type: &str,
|
||||
payload: Value,
|
||||
) -> Result<JournalLine, StoreError> {
|
||||
self.journal.append(line_type, payload)
|
||||
let line = self.journal.append(line_type, payload)?;
|
||||
// The command that posts the entry runs after its line is
|
||||
// journaled: tell the ledger which line that is (plan P4).
|
||||
if matches!(line_type, "entry.posted" | "entry.reversed")
|
||||
&& let Some(id) = line.payload.get("id").and_then(Value::as_u64)
|
||||
{
|
||||
self.ledger.expect_posted_seq(id, line.seq);
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// Add an account to the chart, with no tax category (see
|
||||
@@ -1431,6 +1439,67 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set where a draft stands (`entry.draft_state_set`, plan P4): still
|
||||
/// being built, or awaiting its evidence with a note saying what.
|
||||
/// Journaled with the feature `drafts_v2` (an older version refuses
|
||||
/// the type). A state moves no figure and projects nowhere but the
|
||||
/// `draft_states` table.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a draft that is finished and waiting on its receipt says so,
|
||||
/// durably, and replays the same.
|
||||
/// SOURCE: docs/spec/rules.md B-27, B-43; plan P4.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the engine refuses it (unknown or posted entry, a note the
|
||||
/// books won't keep): SHOULD refuse, journaling nothing.
|
||||
/// 2. IF the books haven't declared drafts_v2: SHOULD need the
|
||||
/// feature, journaling nothing.
|
||||
/// ```
|
||||
pub fn set_draft_state(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
state: ledger_core::DraftState,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::DRAFTS_V2)?;
|
||||
self.rehearse(|l| l.set_draft_state(entry, state.clone()))?;
|
||||
let line = self.append_journal(
|
||||
"entry.draft_state_set",
|
||||
crate::meta::draft_state_set(entry, &state),
|
||||
)?;
|
||||
self.ledger.set_draft_state(entry, state)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Throw a draft away (`entry.draft_discarded`, plan P4), with the
|
||||
/// reason; journaled with the feature `drafts_v2`. The draft's lines
|
||||
/// stay in the journal; the draft leaves the ledger. A posted entry is
|
||||
/// never discarded.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a draft that will never post can go, so it stops holding its
|
||||
/// period open, and the journal still says why.
|
||||
/// SOURCE: docs/spec/rules.md B-8, B-43; plan P4.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the entry is unknown or posted, or the reason is empty or
|
||||
/// breaks the books' text rule: SHOULD refuse, journaling
|
||||
/// nothing.
|
||||
/// 2. IF the books haven't declared drafts_v2: SHOULD need the
|
||||
/// feature, journaling nothing.
|
||||
/// ```
|
||||
pub fn discard_draft(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::DRAFTS_V2)?;
|
||||
self.rehearse(|l| l.discard_draft(entry, reason))?;
|
||||
let reason = reason.trim();
|
||||
let line = self.append_journal(
|
||||
"entry.draft_discarded",
|
||||
crate::meta::draft_discarded(entry, reason),
|
||||
)?;
|
||||
self.ledger.discard_draft(entry, reason)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate, journal, and post a draft entry. An entry an enforced
|
||||
/// approval rule covers posts only once its approvers approved it as
|
||||
/// it stands (ledger_core::conventions INVARIANT 3).
|
||||
@@ -2905,6 +2974,8 @@ impl Store {
|
||||
}),
|
||||
)?;
|
||||
self.ledger = next;
|
||||
// The closing entry was posted by this line (plan P4, posted_seq).
|
||||
self.ledger.stamp_unstamped(line.seq);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(closing_entry)
|
||||
}
|
||||
@@ -4859,6 +4930,8 @@ impl Store {
|
||||
}),
|
||||
)?;
|
||||
self.ledger = next;
|
||||
// The month's entry was posted by this line (plan P4, posted_seq).
|
||||
self.ledger.stamp_unstamped(line.seq);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(entry)
|
||||
}
|
||||
@@ -7091,7 +7164,10 @@ pub(crate) fn replay_line_into_ledger(
|
||||
line: &JournalLine,
|
||||
base_currency: Currency,
|
||||
) -> Result<(), StoreError> {
|
||||
ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))
|
||||
ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))?;
|
||||
// Whatever this line posted was posted by it (plan P4, posted_seq).
|
||||
ledger.stamp_unstamped(line.seq);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`replay_line_into_ledger`]'s work.
|
||||
@@ -7206,6 +7282,14 @@ fn replay_line(
|
||||
}
|
||||
ledger.replay_post_entry(id)?;
|
||||
}
|
||||
"entry.draft_state_set" => {
|
||||
let (id, state) = crate::meta::draft_state_set_of(line)?;
|
||||
ledger.replay_set_draft_state(id, state)?;
|
||||
}
|
||||
"entry.draft_discarded" => {
|
||||
let (id, _) = crate::meta::draft_discarded_of(p)?;
|
||||
ledger.replay_discard_draft(id)?;
|
||||
}
|
||||
"entry.reversed" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let orig = get_u64(p, "reverses")?;
|
||||
@@ -7994,11 +8078,16 @@ fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result<CloseT
|
||||
/// Record a posted entry that rides inside another event's payload (a
|
||||
/// closing entry, a schedule posting) in the projection, like any posted
|
||||
/// entry.
|
||||
fn project_posted_entry(conn: &Connection, e: &Value) -> Result<(), StoreError> {
|
||||
fn project_posted_entry(conn: &Connection, e: &Value, seq: u64) -> Result<(), StoreError> {
|
||||
let eid = get_u64(e, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',NULL,?)",
|
||||
params![
|
||||
eid,
|
||||
get_str(e, "date")?,
|
||||
get_str(e, "description")?,
|
||||
seq as i64
|
||||
],
|
||||
)?;
|
||||
for l in e
|
||||
.get("lines")
|
||||
@@ -8109,16 +8198,53 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
// Draft lines are durable in the journal but never touch the
|
||||
// projection until posted.
|
||||
"entry.line_added" => {}
|
||||
// A draft awaiting its evidence has a row; one being built (the
|
||||
// default) has none; a posted or discarded draft none (plan P4).
|
||||
"entry.draft_state_set" => {
|
||||
let (id, state) = crate::meta::draft_state_set_of(line)?;
|
||||
match state {
|
||||
ledger_core::DraftState::Building => {
|
||||
conn.execute(
|
||||
"DELETE FROM draft_states WHERE entry_id = ?",
|
||||
params![id as i64],
|
||||
)?;
|
||||
}
|
||||
ledger_core::DraftState::AwaitingEvidence { note } => {
|
||||
conn.execute(
|
||||
"INSERT INTO draft_states(entry_id, state, note) VALUES (?,?,?) \
|
||||
ON CONFLICT(entry_id) DO UPDATE SET state = excluded.state, note = excluded.note",
|
||||
params![
|
||||
id as i64,
|
||||
ledger_core::DraftState::AwaitingEvidence { note: note.clone() }.key(),
|
||||
(!note.is_empty()).then_some(note),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"entry.draft_discarded" => {
|
||||
let (id, _) = crate::meta::draft_discarded_of(p)?;
|
||||
conn.execute(
|
||||
"DELETE FROM draft_states WHERE entry_id = ?",
|
||||
params![id as i64],
|
||||
)?;
|
||||
}
|
||||
"entry.posted" => {
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)",
|
||||
params![
|
||||
get_u64(p, "id")? as i64,
|
||||
get_str(p, "date")?,
|
||||
get_str(p, "description")?,
|
||||
p.get("reversal_of").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
// A posted entry waits on nothing (plan P4).
|
||||
conn.execute(
|
||||
"DELETE FROM draft_states WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64],
|
||||
)?;
|
||||
for l in p
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -8153,12 +8279,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)",
|
||||
params![
|
||||
get_u64(p, "id")? as i64,
|
||||
get_str(p, "date")?,
|
||||
get_str(p, "description")?,
|
||||
get_u64(p, "reverses")? as i64,
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
for l in p
|
||||
@@ -8498,7 +8625,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
"schedule.registered" | "recurring.registered" | "recurring.generated" => {
|
||||
// Master data and drafts: the ledger holds them; no report rows.
|
||||
}
|
||||
"schedule.posted" => project_posted_entry(conn, &p["entry"])?,
|
||||
"schedule.posted" => project_posted_entry(conn, &p["entry"], line.seq)?,
|
||||
"receipt.unlinked" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
|
||||
@@ -8624,7 +8751,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
// posted entry so the projection stays complete, then mark the
|
||||
// period row closed.
|
||||
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
|
||||
project_posted_entry(conn, &p["entry"])?;
|
||||
project_posted_entry(conn, &p["entry"], line.seq)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",
|
||||
|
||||
Reference in new issue
Block a user