Lines
76 %
Functions
44.44 %
Branches
100 %
use finance::price::Price;
use finance::split::Split;
use finance::tag::Tag;
use finance::transaction::Transaction;
use scripting::error::HookError;
use scripting::runtime::{classify_runtime_error, err_code_and_message};
use scripting::{
ContextType, EntityData, EntityType, MemorySerializer, Operation, ParsedEntity, ScriptExecutor,
};
use sqlx::types::Uuid;
use sqlx::types::chrono::{DateTime, TimeZone, Utc};
use std::collections::HashMap;
use crate::command::FinanceEntity;
use crate::error::ServerError;
/// One per-script failure observation from a batch `run_scripts` invocation.
/// `code` is a kebab-case symbol mirroring the wire envelope of catch-each
/// result cells (so emacs / cli / web clients render script failures the
/// same shape regardless of whether the script raised inside catch-each
/// or surfaced at the outer batch boundary). `message` is the engine's
/// diagnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptFailure {
pub script_id: Uuid,
pub code: String,
pub message: String,
}
/// Aggregate output of a batch `run_scripts` call. `state` is the final
/// `TransactionState` after every script that succeeded was applied;
/// `failures` lists every script that errored (in execution order).
/// Failures don't abort the run — successive scripts still see the
/// state mutations from previous successful ones.
pub struct ScriptRunReport {
pub state: TransactionState,
pub failures: Vec<ScriptFailure>,
const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
type IndexTable = HashMap<u32, (EntityType, Uuid)>;
pub struct TransactionState {
pub transaction: Transaction,
pub splits: Vec<Split>,
pub transaction_tags: Vec<Tag>,
pub split_tags: Vec<(Uuid, Tag)>,
pub prices: Vec<Price>,
/// Display name per posting account id, so the serializer can expose
/// `SPLIT-ACCOUNT-NAME` to trigger scripts without an in-script account
/// lookup. Empty for an account whose name is unknown at build time.
pub account_names: HashMap<Uuid, String>,
impl TransactionState {
#[must_use]
pub fn new(transaction: Transaction) -> Self {
Self {
transaction,
splits: Vec::new(),
transaction_tags: Vec::new(),
split_tags: Vec::new(),
prices: Vec::new(),
account_names: HashMap::new(),
pub fn with_account_names(mut self, names: HashMap<Uuid, String>) -> Self {
self.account_names = names;
self
pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
for entity in entities {
match entity {
FinanceEntity::Split(s) => self.splits.push(s),
FinanceEntity::Price(p) => self.prices.push(p),
FinanceEntity::Tag(t) => self.transaction_tags.push(t),
_ => {}
pub fn with_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
self.split_tags = tags;
pub fn with_note(mut self, note: Option<String>) -> Self {
if let Some(note) = note {
self.transaction_tags.push(Tag {
id: Uuid::new_v4(),
tag_name: "note".to_string(),
tag_value: note,
description: None,
});
/// Runs each script against the current state, accumulating
/// per-script failures into a structured `ScriptRunReport` rather
/// than swallowing them silently. The `state` field reflects every
/// script that successfully produced entities; failed scripts are
/// recorded in `failures` and don't abort the batch — subsequent
/// scripts still observe state mutations from earlier successes.
///
/// The outer `Result` reserves `ServerError` for genuine
/// orchestration failures (entity-apply errors), keeping
/// script-side failures structurally separate. Callers that want
/// the previous "first-failure-aborts" semantics can chain with
/// `ScriptRunReport::into_state_or_first_failure`.
pub fn run_scripts(
mut self,
executor: &ScriptExecutor,
scripts: &[(Uuid, Vec<u8>)],
) -> Result<ScriptRunReport, ServerError> {
let mut failures: Vec<ScriptFailure> = Vec::new();
for (script_id, bytecode) in scripts {
let (input, mut index_table) = serialize_state(&self);
match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
Ok(entities) if !entities.is_empty() => {
apply_parsed_entities(&mut self, entities, &mut index_table)?;
Ok(_) => {}
Err(e) => {
failures.push(classify_script_failure(*script_id, &e));
Ok(ScriptRunReport {
state: self,
failures,
})
/// Builds a [`TransactionState`] for a single transaction id by
/// fetching the transaction, its splits, and any existing `note`
/// tag through the public `server::command::*` API. Used by the
/// batch-script runner to feed a per-transaction state to
/// `run_scripts` without re-implementing the read path.
/// Each query trips through the typestate runners in
/// `server::command::*`, so behaviour matches what the rpc
/// natives surface — single dispatch surface honored.
pub async fn load_transaction_state(
user_id: Uuid,
transaction_id: Uuid,
) -> Result<Option<TransactionState>, ServerError> {
use crate::command::transaction::GetTransaction;
use crate::command::{CmdResult, FinanceEntity};
let tx_result = GetTransaction::new()
.user_id(user_id)
.transaction_id(transaction_id)
.run()
.await
.map_err(|e| ServerError::Script(format!("get-transaction {transaction_id}: {e:?}")))?;
let Some(CmdResult::TaggedTransactions { mut entities, .. }) = tx_result else {
return Ok(None);
let Some((FinanceEntity::Transaction(tx), tags, _amount)) = entities.pop() else {
let note = tags.get("note").and_then(|entity| match entity {
FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
_ => None,
let splits_result = crate::command::split::ListSplits::new()
.transaction(transaction_id)
.map_err(|e| ServerError::Script(format!("list-splits {transaction_id}: {e:?}")))?;
let mut split_entities: Vec<FinanceEntity> = Vec::new();
if let Some(CmdResult::TaggedEntities {
entities: split_data,
..
}) = splits_result
{
for (entity, _tags) in split_data {
split_entities.push(entity);
let account_names = load_account_names(user_id, &split_entities).await?;
Ok(Some(
TransactionState::new(tx)
.with(split_entities)
.with_note(note)
.with_account_names(account_names),
))
/// Resolves the display name (the `name` tag) of every distinct posting
/// account referenced by `split_entities`, through the public command API.
async fn load_account_names(
split_entities: &[FinanceEntity],
) -> Result<HashMap<Uuid, String>, ServerError> {
use crate::command::account::GetAccount;
let mut names: HashMap<Uuid, String> = HashMap::new();
for entity in split_entities {
let FinanceEntity::Split(split) = entity else {
continue;
if names.contains_key(&split.account_id) {
let result = GetAccount::new()
.account_id(split.account_id)
.map_err(|e| ServerError::Script(format!("get-account {}: {e:?}", split.account_id)))?;
if let Some(CmdResult::TaggedEntities { entities, .. }) = result
&& let Some((_, tags)) = entities.first()
&& let Some(FinanceEntity::Tag(tag)) = tags.get("name")
names.insert(split.account_id, tag.tag_value.clone());
Ok(names)
fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
let mut serializer = MemorySerializer::new();
let mut index_table = IndexTable::new();
serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
let is_multi_currency = state
.splits
.iter()
.map(|s| s.commodity_id)
.collect::<std::collections::HashSet<_>>()
.len()
> 1;
let tx_idx = serializer.add_transaction_from(scripting::TransactionFromArgs {
transaction: &state.transaction,
is_primary: true,
split_count: state.splits.len() as u32,
tag_count: state.transaction_tags.len() as u32,
is_multi_currency,
serializer.set_primary(tx_idx);
index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
for split in &state.splits {
let account_name = state
.account_names
.get(&split.account_id)
.map_or("", String::as_str);
let split_idx = serializer.add_split_from(split, tx_idx as i32, account_name);
split_indices.push((split.id, split_idx));
index_table.insert(split_idx, (EntityType::Split, split.id));
for tag in &state.transaction_tags {
serializer.add_tag(
*tag.id.as_bytes(),
tx_idx as i32,
false,
&tag.tag_name,
&tag.tag_value,
);
for (split_id, tag) in &state.split_tags {
let parent_idx = split_indices
.find(|(id, _)| id == split_id)
.map_or(-1, |(_, idx)| *idx as i32);
parent_idx,
(serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
fn apply_parsed_entities(
state: &mut TransactionState,
entities: Vec<ParsedEntity>,
index_table: &mut IndexTable,
) -> Result<(), ServerError> {
let mut current_output_idx = index_table.len() as u32;
let entity_id = Uuid::from_bytes(entity.id);
match (entity.entity_type, entity.operation) {
(EntityType::Tag, Operation::Create) => {
if let EntityData::Tag { name, value } = entity.data {
let tag_id = Uuid::new_v4();
let tag = Tag {
id: tag_id,
tag_name: name.clone(),
tag_value: value.clone(),
match index_table.get(&(entity.parent_idx as u32)) {
Some(&(EntityType::Transaction, tx_id)) => {
log::debug!(
"script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
state.transaction_tags.push(tag);
Some(&(EntityType::Split, split_id)) => {
"script: create tag \"{name}\"=\"{value}\" on split {split_id}"
state.split_tags.push((split_id, tag));
_ => {
log::warn!(
"Tag parent_idx {} not found in index table",
entity.parent_idx
index_table.insert(current_output_idx, (EntityType::Tag, tag_id));
current_output_idx += 1;
(EntityType::Split, Operation::Create) => {
if let EntityData::Split {
account_id,
commodity_id,
value_num,
value_denom,
reconcile_state,
reconcile_date,
} = entity.data
let account_id = Uuid::from_bytes(account_id);
let commodity_id = Uuid::from_bytes(commodity_id);
let split_id = Uuid::new_v4();
"script: create split {split_id} account={account_id} value={value_num}/{value_denom}"
let split = Split {
id: split_id,
tx_id: state.transaction.id,
reconcile_state: if reconcile_state == 0 {
None
} else {
Some(reconcile_state != 0)
},
reconcile_date: if reconcile_date == 0 {
Some(
Utc.timestamp_millis_opt(reconcile_date)
.single()
.unwrap_or_default(),
)
lot_id: None,
state.splits.push(split);
index_table.insert(current_output_idx, (EntityType::Split, split_id));
(EntityType::Split, Operation::Update) => {
&& let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
log::debug!("script: update split {entity_id} value={value_num}/{value_denom}");
split.account_id = Uuid::from_bytes(account_id);
split.commodity_id = Uuid::from_bytes(commodity_id);
split.value_num = value_num;
split.value_denom = value_denom;
split.reconcile_state = if reconcile_state == 0 {
split.reconcile_date = if reconcile_date == 0 {
(EntityType::Transaction, Operation::Update) => {
if let EntityData::Transaction {
post_date,
enter_date,
log::debug!("script: update transaction {entity_id}");
state.transaction.post_date = millis_to_datetime(post_date);
state.transaction.enter_date = millis_to_datetime(enter_date);
(EntityType::Split, Operation::Delete) => {
log::debug!("script: delete split {entity_id}");
state.splits.retain(|s| s.id != entity_id);
state.split_tags.retain(|(id, _)| *id != entity_id);
(EntityType::Tag, Operation::Delete) => {
log::debug!("script: delete tag {entity_id}");
state.transaction_tags.retain(|t| t.id != entity_id);
state.split_tags.retain(|(_, t)| t.id != entity_id);
Ok(())
fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
Utc.timestamp_millis_opt(millis)
.unwrap_or_default()
/// Maps a `HookError` from a single batch-script run into a structured
/// `ScriptFailure`. Wasm-engine errors classify through the same
/// `EngineError` pipeline catch-each uses (`OutOfFuel`, `ScriptRaised`,
/// `NoConversion`, ...) so client renderers see one shape no matter where
/// in the stack the failure originated. A commodity mismatch arrives as a
/// `ScriptRaised{code:"commodity-mismatch"}` (it `throw`s `$nomi_error`
/// in-guest; ADR-0026). Non-engine variants (Parse, Lock, ...) get a
/// `runtime` code with the engine's own message.
fn classify_script_failure(script_id: Uuid, err: &HookError) -> ScriptFailure {
let (code, message) = match err {
HookError::WASM(wasm_err) => err_code_and_message(&classify_runtime_error(wasm_err)),
HookError::Engine(engine_err) => err_code_and_message(engine_err),
other => ("runtime".to_string(), format!("{other}")),
ScriptFailure {
script_id,
code,
message,
#[cfg(test)]
mod tests {
use super::*;
use finance::transaction::TransactionBuilder;
use sqlx::types::chrono::Local;
#[test]
fn test_transaction_state_new() {
let tx = TransactionBuilder::new()
.id(Uuid::new_v4())
.post_date(Local::now().into())
.enter_date(Local::now().into())
.build()
.unwrap();
let state = TransactionState::new(tx);
assert!(state.splits.is_empty());
assert!(state.transaction_tags.is_empty());
assert!(state.split_tags.is_empty());
assert!(state.prices.is_empty());
fn test_serialize_empty_state() {
let (bytes, index_table) = serialize_state(&state);
assert!(!bytes.is_empty());
assert!(
index_table.contains_key(&0),
"transaction missing at index 0"
fn test_apply_tag_to_transaction() {
let tx_id = Uuid::new_v4();
.id(tx_id)
let mut state = TransactionState::new(tx);
index_table.insert(0, (EntityType::Transaction, tx_id));
let tag_entity = ParsedEntity {
entity_type: EntityType::Tag,
operation: Operation::Create,
flags: 0,
id: *Uuid::new_v4().as_bytes(),
parent_idx: 0, // Points to transaction at index 0
data: EntityData::Tag {
name: "category".to_string(),
value: "groceries".to_string(),
apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
assert_eq!(state.transaction_tags.len(), 1);
assert_eq!(state.transaction_tags[0].tag_name, "category");
assert_eq!(state.transaction_tags[0].tag_value, "groceries");
fn test_apply_tag_to_split() {
tx_id,
account_id: Uuid::new_v4(),
commodity_id: Uuid::new_v4(),
value_num: 100,
value_denom: 1,
reconcile_state: None,
reconcile_date: None,
let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
index_table.insert(1, (EntityType::Split, split_id));
parent_idx: 1, // Points to split at index 1
assert_eq!(state.split_tags.len(), 1);
assert_eq!(state.split_tags[0].0, split_id);
assert_eq!(state.split_tags[0].1.tag_name, "category");
assert_eq!(state.split_tags[0].1.tag_value, "groceries");
fn test_serialize_state_with_split_tags() {
tag_name: "category".to_string(),
tag_value: "food".to_string(),
let state = TransactionState::new(tx)
.with(vec![FinanceEntity::Split(split)])
.with_split_tags(vec![(split_id, tag)]);
assert_eq!(index_table.len(), 2); // transaction + split
assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
fn test_millis_to_datetime() {
let dt = millis_to_datetime(1704067200000);
assert_eq!(dt.timestamp(), 1704067200);
fn test_tag_sync_copies_split_tags_to_transaction() {
const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
let split1_id = Uuid::new_v4();
let split2_id = Uuid::new_v4();
let commodity_id = Uuid::new_v4();
let split1 = Split {
id: split1_id,
value_num: -5000,
value_denom: 100,
let split2 = Split {
id: split2_id,
value_num: 5000,
let category_tag = Tag {
.with(vec![
FinanceEntity::Split(split1),
FinanceEntity::Split(split2),
])
.with_note(Some("groceries".to_string()))
.with_split_tags(vec![(split1_id, category_tag)]);
let script_id = Uuid::new_v4();
let executor = ScriptExecutor::try_new().expect("baseline engine");
let report = state
.run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
.expect("run_scripts failed");
report.failures.is_empty(),
"tag_sync.wasm must run cleanly: {:?}",
report.failures
let new_tx_tags: Vec<_> = report
.state
.transaction_tags
.filter(|t| t.tag_name != "note")
.collect();
assert_eq!(
new_tx_tags.len(),
1,
"tag_sync should copy category tag from split to transaction"
assert_eq!(new_tx_tags[0].tag_name, "category");
assert_eq!(new_tx_tags[0].tag_value, "food");
/// Bad bytecode lands as a structured `ScriptFailure` in the report
/// rather than being silently swallowed. The state is preserved
/// (no side effects from a failed script).
fn run_scripts_captures_failed_script_into_structured_report() {
let invalid_bytecode: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
.run_scripts(&executor, &[(script_id, invalid_bytecode)])
.expect("run_scripts must surface bad-bytecode as a failure cell, not an outer Err");
report.failures.len(),
"single bad script should produce exactly one captured failure"
assert_eq!(report.failures[0].script_id, script_id);
!report.failures[0].code.is_empty(),
"captured failure must carry a non-empty code symbol"
report.state.transaction_tags.is_empty(),
"failed script must not have mutated state"
assert_eq!(report.state.transaction.id, tx_id);
/// A mixed batch — one failing script followed by a working one —
/// captures the failure but still applies the second script's
/// output. Validates the "successive scripts still see state
/// mutations from earlier successes" invariant of the report shape.
/// Setup mirrors `test_tag_sync_copies_split_tags_to_transaction`'s
/// two-split balanced shape because tag_sync.wasm only fires once
/// the transaction's splits sum to zero.
fn run_scripts_continues_past_failure_and_applies_subsequent_scripts() {
let bad_id = Uuid::new_v4();
let good_id = Uuid::new_v4();
.run_scripts(
&executor,
&[
(bad_id, vec![0xde, 0xad, 0xbe, 0xef]),
(good_id, TAG_SYNC_WASM.to_vec()),
],
.expect("run_scripts must capture per-script failures structurally");
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].script_id, bad_id);
let category_tx_tags: Vec<_> = report
.filter(|t| t.tag_name == "category")
category_tx_tags.len(),
"tag_sync.wasm must still copy the split's category tag onto the transaction \
after an earlier script failed"
/// `classify_script_failure` routes engine-classified errors
/// through `err_code_and_message` so the code symbols match catch-each's
/// err cells. A commodity mismatch is the load-bearing case — it now
/// `throw`s `$nomi_error` in-guest and arrives as a `ScriptRaised`
/// carrying the reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026),
/// the structural signal scripts react to, distinct from generic traps.
fn classify_script_failure_maps_engine_error_to_symbol_code() {
use scripting::runtime::EngineError;
let engine_err = EngineError::ScriptRaised {
code: "COMMODITY-MISMATCH".to_string(),
message: "USD vs EUR".to_string(),
let hook_err = HookError::Engine(engine_err);
let id = Uuid::new_v4();
let failure = classify_script_failure(id, &hook_err);
assert_eq!(failure.script_id, id);
assert_eq!(failure.code, "COMMODITY-MISMATCH");
assert_eq!(failure.message, "USD vs EUR");
/// Non-engine `HookError` variants (Parse / Lock / Script) fall
/// through to a `runtime` code with the engine's own message —
/// the catch-all path that keeps `ScriptFailure`'s shape
/// non-empty for any cause `executor.execute` can surface.
fn classify_script_failure_falls_back_to_runtime_for_non_engine_error() {
let hook_err = HookError::Script("synthetic test failure".to_string());
assert_eq!(failure.code, "runtime");
assert!(failure.message.contains("synthetic test failure"));