Skip to main content

rustledger_wasm/
api.rs

1//! Public WASM API functions.
2//!
3//! These functions are exposed to JavaScript via wasm-bindgen.
4
5use std::collections::HashMap;
6use std::path::Path;
7use wasm_bindgen::prelude::*;
8
9use rustledger_core::Directive;
10use rustledger_loader::{FileSystem, LoadError, LoadResult};
11use rustledger_parser::parse as parse_beancount;
12
13use crate::convert::{directive_to_json, value_to_cell};
14use crate::helpers::{
15    extract_options, has_fatal, load_and_book, parse_error_to_wasm, run_validation, to_js,
16};
17#[cfg(feature = "completions")]
18use crate::types::{CompletionJson, CompletionResultJson};
19use crate::types::{
20    Error, FormatResult, Ledger, PadResult, ParseResult, QueryResult, Severity, ValidationResult,
21};
22#[cfg(feature = "plugins")]
23use crate::types::{PluginInfo, PluginResult};
24use crate::utils::LineLookup;
25
26/// Convert [`LoadResult`] errors to detailed Error objects with line/column info.
27///
28/// This preserves parse error details that would be lost by simple `to_string()`.
29fn load_errors_to_errors(load_result: &LoadResult) -> Vec<Error> {
30    let mut errors = Vec::new();
31
32    for load_error in &load_result.errors {
33        match load_error {
34            LoadError::ParseErrors {
35                path,
36                errors: parse_errors,
37            } => {
38                // Expand parse errors with the same rich fields as the
39                // single-file path (code / phase / hint / file / full span).
40                for parse_error in parse_errors {
41                    let span = parse_error.span();
42                    let file = load_result.source_map.get_by_path(path);
43                    let mut err = Error::new(format!("{}: {}", path.display(), parse_error))
44                        .with_code(format!("P{:04}", parse_error.kind_code()))
45                        .with_phase("parse")
46                        .with_hint(parse_error.hint.clone())
47                        .with_file(Some(path.display().to_string()));
48                    if let Some(file) = file {
49                        let (sl, sc) = file.line_col(span.0);
50                        let (el, ec) = file.line_col(span.1);
51                        err = err.with_span((sl as u32, sc as u32), (el as u32, ec as u32));
52                    }
53                    errors.push(err);
54                }
55            }
56            other => {
57                // Other errors use default string conversion
58                errors.push(Error::new(other.to_string()));
59            }
60        }
61    }
62
63    errors
64}
65
66/// Parse a Beancount source string.
67///
68/// Returns a `ParseResult` with the parsed ledger and any errors.
69#[wasm_bindgen]
70pub fn parse(source: &str) -> Result<JsValue, JsError> {
71    let result = parse_beancount(source);
72    let lookup = LineLookup::new(source);
73
74    let errors: Vec<Error> = result
75        .errors
76        .iter()
77        .map(|e| parse_error_to_wasm(e, &lookup, None))
78        .collect();
79
80    // Extract options from parsed result
81    let options = extract_options(&result.options);
82
83    let ledger = Some(Ledger {
84        directives: result
85            .directives
86            .iter()
87            .map(|spanned| directive_to_json(&spanned.value))
88            .collect(),
89        options,
90    });
91
92    let parse_result = ParseResult { ledger, errors };
93    to_js(&parse_result)
94}
95
96/// Validate a Beancount source string.
97///
98/// Parses, interpolates, and validates in one step.
99/// Returns a `ValidationResult` indicating whether the ledger is valid.
100#[wasm_bindgen(js_name = "validateSource")]
101pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
102    let load = load_and_book(source);
103    let validation_errors = run_validation(&load);
104    let mut errors = load.errors;
105    errors.extend(validation_errors);
106
107    let result = ValidationResult {
108        // Warnings do not invalidate a ledger (matching `rledger check`, which
109        // exits 0 on warning-only input); only actual errors do.
110        valid: !has_fatal(&errors),
111        errors,
112    };
113    to_js(&result)
114}
115
116/// Run a BQL query on a Beancount source string.
117///
118/// Parses the source, interpolates, then executes the query.
119/// Returns a `QueryResult` with columns, rows, and any errors.
120#[wasm_bindgen]
121pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
122    use rustledger_booking::merge_with_padding;
123    use rustledger_query::{Executor, parse as parse_query};
124
125    let load = load_and_book(source);
126
127    // Return early only on actual errors (parse/booking); warnings must not
128    // abort processing.
129    if has_fatal(&load.errors) {
130        let result = QueryResult {
131            columns: Vec::new(),
132            rows: Vec::new(),
133            errors: load.errors,
134        };
135        return to_js(&result);
136    }
137
138    // Carry any non-fatal load warnings through every result path so callers
139    // still see them alongside (or instead of) query output.
140    let warnings = load.errors;
141
142    // Parse the query
143    let query = match parse_query(query_str) {
144        Ok(q) => q,
145        Err(e) => {
146            let mut errors = warnings;
147            errors.push(Error::new(e.to_string()));
148            let result = QueryResult {
149                columns: Vec::new(),
150                rows: Vec::new(),
151                errors,
152            };
153            return to_js(&result);
154        }
155    };
156
157    // Merge pad-synthesized transactions: query is a balance-
158    // computing consumer (#1288). `merge_with_padding` preserves
159    // Pad directives so `FROM #entries WHERE type = 'pad'` audits
160    // continue to enumerate them, AND handles multi-pad shadowing
161    // (#1300) correctly by construction via `process_pads`.
162    let directives = merge_with_padding(&load.directives);
163    let mut executor = Executor::new(&directives);
164    // Config-aware classification (POSSIGN/ACCOUNT_SORTKEY honor name_*
165    // renames) — same wiring as the CLI query path.
166    executor.set_account_types(crate::helpers::account_types_from_raw(
167        &load.parse_result.options,
168    ));
169    match executor.execute(&query) {
170        Ok(result) => {
171            let rows: Vec<Vec<_>> = result
172                .rows
173                .iter()
174                .map(|row| row.iter().map(value_to_cell).collect())
175                .collect();
176
177            let query_result = QueryResult {
178                columns: result.columns,
179                rows,
180                errors: warnings,
181            };
182            to_js(&query_result)
183        }
184        Err(e) => {
185            let mut errors = warnings;
186            errors.push(Error::new(format!("Query execution error: {e}")));
187            let result = QueryResult {
188                columns: Vec::new(),
189                rows: Vec::new(),
190                errors,
191            };
192            to_js(&result)
193        }
194    }
195}
196
197/// Get version information.
198///
199/// Returns the version string of the rustledger-wasm package.
200#[wasm_bindgen]
201pub fn version() -> String {
202    env!("CARGO_PKG_VERSION").to_string()
203}
204
205/// Format a Beancount source string.
206///
207/// Parses and reformats with consistent alignment.
208/// Returns a `FormatResult` with the formatted source or errors.
209#[wasm_bindgen]
210pub fn format(source: &str) -> Result<JsValue, JsError> {
211    use rustledger_parser::format::format_source_with_parsed;
212
213    let parse_result = parse_beancount(source);
214    let lookup = LineLookup::new(source);
215
216    if !parse_result.errors.is_empty() {
217        let result = FormatResult {
218            formatted: None,
219            errors: parse_result
220                .errors
221                .iter()
222                .map(|e| parse_error_to_wasm(e, &lookup, None))
223                .collect(),
224        };
225        return to_js(&result);
226    }
227
228    // Reuse the `parse_result` we produced for the error gate above
229    // instead of letting `format_source` re-parse. Byte-identical
230    // output per parser-side `format_source_with_parsed_matches_format_source`.
231    let formatted = format_source_with_parsed(&parse_result, source);
232
233    let result = FormatResult {
234        formatted: Some(formatted),
235        errors: Vec::new(),
236    };
237    to_js(&result)
238}
239
240/// Process pad directives and expand them.
241///
242/// Returns directives with pad-generated transactions included.
243#[wasm_bindgen(js_name = "expandPads")]
244pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
245    use rustledger_booking::process_pads;
246
247    let load = load_and_book(source);
248
249    // Return early only on actual errors (parse/booking); warnings must not
250    // abort processing.
251    if has_fatal(&load.errors) {
252        let result = PadResult {
253            directives: Vec::new(),
254            padding_transactions: Vec::new(),
255            errors: load.errors,
256        };
257        return to_js(&result);
258    }
259
260    // Carry non-fatal load warnings through to the result.
261    let mut errors = load.errors;
262
263    // Process pads
264    let pad_result = process_pads(&load.directives);
265    errors.extend(
266        pad_result
267            .errors
268            .iter()
269            .map(|e| Error::new(e.message.clone())),
270    );
271
272    let result = PadResult {
273        // The source stream, verbatim — `process_pads` no longer
274        // echoes its input back, so read it from the directives we
275        // already loaded instead of from the result.
276        directives: load.directives.iter().map(directive_to_json).collect(),
277        padding_transactions: pad_result
278            .padding_transactions
279            .iter()
280            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
281            .collect(),
282        errors,
283    };
284    to_js(&result)
285}
286
287/// Materialize a plugin's `ops` against its input wrapper list,
288/// producing the resulting flat wrapper list. Used by WASM entry
289/// points that need to round-trip a plugin's output back to
290/// `Vec<Directive>` for JSON serialization.
291#[cfg(feature = "plugins")]
292pub fn materialize_plugin_ops(
293    input: &[rustledger_plugin::types::DirectiveWrapper],
294    output: &rustledger_plugin::types::PluginOutput,
295) -> Vec<rustledger_plugin::types::DirectiveWrapper> {
296    let mut out = Vec::with_capacity(output.ops.len());
297    for op in &output.ops {
298        match op {
299            rustledger_plugin::PluginOp::Keep(i) => {
300                if let Some(w) = input.get(*i) {
301                    out.push(w.clone());
302                }
303            }
304            rustledger_plugin::PluginOp::Modify(_, w) | rustledger_plugin::PluginOp::Insert(w) => {
305                out.push(w.clone());
306            }
307            rustledger_plugin::PluginOp::Delete(_) => {}
308        }
309    }
310    out
311}
312
313/// Run a single named plugin against a Beancount source and return
314/// the resulting directives as JSON.
315#[cfg(feature = "plugins")]
316#[wasm_bindgen(js_name = "runPlugin")]
317pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
318    use rustledger_plugin::{
319        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
320        wrappers_to_directives,
321    };
322
323    let load = load_and_book(source);
324
325    // Return early only on actual errors (parse/booking); warnings must not
326    // abort processing.
327    if has_fatal(&load.errors) {
328        let result = PluginResult {
329            directives: Vec::new(),
330            errors: load.errors,
331        };
332        return to_js(&result);
333    }
334
335    // Carry non-fatal load warnings through every result path.
336    let warnings = load.errors;
337
338    // Find and run the plugin
339    let registry = NativePluginRegistry::global();
340    // External API runs plugins on already-booked input — synth
341    // plugins are a loader-internal concern and would re-emit Opens
342    // for accounts the booking pass already opened.
343    let Some(plugin) = registry.find_regular(plugin_name) else {
344        let mut errors = warnings;
345        errors.push(Error::new(format!("Unknown plugin: {plugin_name}")));
346        let result = PluginResult {
347            directives: Vec::new(),
348            errors,
349        };
350        return to_js(&result);
351    };
352
353    // Convert directives to plugin format and run
354    let wrappers = directives_to_wrappers(&load.directives);
355    let input = PluginInput {
356        directives: wrappers,
357        options: PluginOptions::default(),
358        config: None,
359    };
360
361    let input_dirs = input.directives.clone();
362    let output = plugin.process(input);
363
364    // Materialize ops back to wrappers, then convert.
365    let materialized_wrappers = materialize_plugin_ops(&input_dirs, &output);
366    let output_directives = match wrappers_to_directives(&materialized_wrappers) {
367        Ok(dirs) => dirs,
368        Err(e) => {
369            let mut errors = warnings;
370            errors.push(Error::new(format!("Conversion error: {e}")));
371            let result = PluginResult {
372                directives: Vec::new(),
373                errors,
374            };
375            return to_js(&result);
376        }
377    };
378
379    let mut errors = warnings;
380    errors.extend(output.errors.iter().map(|e| match e.severity {
381        rustledger_plugin::PluginErrorSeverity::Warning => Error::warning(e.message.clone()),
382        rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
383    }));
384    let result = PluginResult {
385        directives: output_directives.iter().map(directive_to_json).collect(),
386        errors,
387    };
388    to_js(&result)
389}
390
391/// List available native plugins.
392///
393/// Returns an array of `PluginInfo` objects with name and description.
394#[cfg(feature = "plugins")]
395#[wasm_bindgen(js_name = "listPlugins")]
396pub fn list_plugins() -> Result<JsValue, JsError> {
397    use rustledger_plugin::NativePluginRegistry;
398
399    let registry = NativePluginRegistry::global();
400    let plugins: Vec<PluginInfo> = registry
401        .iter()
402        .map(|p| PluginInfo {
403            name: p.name().to_string(),
404            description: p.description().to_string(),
405        })
406        .collect();
407
408    to_js(&plugins)
409}
410
411/// Calculate account balances.
412///
413/// Shorthand for `query(source, "BALANCES")`.
414#[wasm_bindgen]
415pub fn balances(source: &str) -> Result<JsValue, JsError> {
416    query(source, "BALANCES")
417}
418
419/// Get BQL query completions at cursor position.
420///
421/// Returns context-aware completions for the BQL query language.
422#[cfg(feature = "completions")]
423#[wasm_bindgen(js_name = "bqlCompletions")]
424pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
425    use rustledger_query::completions;
426
427    let result = completions::complete(partial_query, cursor_pos);
428
429    let json_result = CompletionResultJson {
430        completions: result
431            .completions
432            .into_iter()
433            .map(|c| CompletionJson {
434                text: c.text,
435                category: c.category.as_str().to_string(),
436                description: c.description,
437            })
438            .collect(),
439        context: format!("{:?}", result.context),
440    };
441
442    to_js(&json_result)
443}
444
445/// Parse multiple Beancount files with include resolution.
446///
447/// This function accepts a map of file paths to file contents and an entry point,
448/// resolving `include` directives across the files. This enables multi-file ledgers
449/// in WASM environments where filesystem access is not available.
450///
451/// On a clean parse, the returned directives are run through the same processing
452/// pipeline as `validateMultiFile` / `queryMultiFile` (sort → synth-plugins →
453/// book → regular-plugins, validation excluded), so they are sorted, include
454/// plugin-synthesized `Open`/`Document` directives, and have booked amounts —
455/// consistent with the other multi-file surfaces. If parsing produced errors, the
456/// raw parsed directives are returned instead (the pipeline is not run on a
457/// malformed ledger) alongside those errors.
458///
459/// # Arguments
460///
461/// * `files` - A JavaScript object mapping file paths to their contents.
462///   Example: `{ "main.beancount": "include \"accounts.beancount\"", "accounts.beancount": "..." }`
463/// * `entry_point` - The main file to start loading from (must exist in `files`).
464///
465/// # Returns
466///
467/// A `ParseResult` with the parsed ledger from all files and any errors.
468///
469/// # Example (JavaScript)
470///
471/// ```javascript
472/// const result = parseMultiFile({
473///   "main.beancount": `
474///     include "accounts.beancount"
475///     2024-01-15 * "Coffee"
476///       Expenses:Food  5.00 USD
477///       Assets:Bank
478///   `,
479///   "accounts.beancount": `
480///     2024-01-01 open Assets:Bank USD
481///     2024-01-01 open Expenses:Food USD
482///   `
483/// }, "main.beancount");
484/// ```
485#[wasm_bindgen(js_name = "parseMultiFile")]
486pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
487    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
488
489    // Parse the JavaScript object to a HashMap
490    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
491        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
492
493    if file_map.is_empty() {
494        return Err(JsError::new("Files map cannot be empty"));
495    }
496
497    // Create virtual filesystem with all files
498    let vfs = VirtualFileSystem::from_files(file_map);
499
500    // Check entry point exists using VFS path normalization
501    if !vfs.exists(Path::new(entry_point)) {
502        return Err(JsError::new(&format!(
503            "Entry point '{entry_point}' not found in files map"
504        )));
505    }
506
507    // Create loader with virtual filesystem
508    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
509
510    // Load from entry point
511    let load_result = match loader.load(Path::new(entry_point)) {
512        Ok(result) => result,
513        Err(e) => {
514            let result = ParseResult {
515                ledger: None,
516                errors: vec![Error::new(format!("Load error: {e}"))],
517            };
518            return to_js(&result);
519        }
520    };
521
522    // Collect load errors with detailed parse error info
523    let mut errors = load_errors_to_errors(&load_result);
524
525    // Extract options from loader options
526    let options = crate::types::LedgerOptions {
527        title: load_result.options.title.clone(),
528        operating_currencies: load_result.options.operating_currency.clone(),
529    };
530
531    // Run the canonical processing pipeline (sort → synth-plugins → book →
532    // regular-plugins) on a clean parse, so the directive stream matches what
533    // `validateMultiFile` / `queryMultiFile` see — sorted, with synthesized
534    // Opens/Documents, and booked amounts. (Previously this used a manual
535    // per-transaction interpolate loop that skipped sort/synth/booking, so a JS
536    // consumer got an unsorted, synth-free, merely-interpolated stream.)
537    // Validation is OFF: this is the parse surface, so it surfaces parse + booking
538    // errors but not balance/assertion failures.
539    //
540    // On parse errors we keep the raw directives we managed to parse rather than
541    // book a malformed ledger (mirroring the old "interpolate only when clean").
542    let directives: Vec<Directive> = if errors.is_empty() {
543        let process_options = LoadOptions {
544            validate: false,
545            ..Default::default()
546        };
547        match process(load_result, &process_options) {
548            Ok(ledger) => {
549                errors.extend(ledger.errors.into_iter().map(Error::from));
550                ledger.directives.into_iter().map(|s| s.value).collect()
551            }
552            Err(e) => {
553                let result = ParseResult {
554                    ledger: None,
555                    errors: vec![Error::new(format!("Processing error: {e}"))],
556                };
557                return to_js(&result);
558            }
559        }
560    } else {
561        load_result
562            .directives
563            .into_iter()
564            .map(|s| s.value)
565            .collect()
566    };
567
568    let ledger = Some(Ledger {
569        directives: directives.iter().map(directive_to_json).collect(),
570        options,
571    });
572
573    let result = ParseResult { ledger, errors };
574    to_js(&result)
575}
576
577/// Validate multiple Beancount files with include resolution.
578///
579/// Similar to `parseMultiFile`, but also runs validation.
580/// Returns a `ValidationResult` indicating whether the ledger is valid.
581#[wasm_bindgen(js_name = "validateMultiFile")]
582pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
583    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
584
585    // Parse the JavaScript object to a HashMap
586    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
587        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
588
589    if file_map.is_empty() {
590        return Err(JsError::new("Files map cannot be empty"));
591    }
592
593    // Create virtual filesystem with all files
594    let vfs = VirtualFileSystem::from_files(file_map);
595
596    // Check entry point exists using VFS path normalization
597    if !vfs.exists(Path::new(entry_point)) {
598        return Err(JsError::new(&format!(
599            "Entry point '{entry_point}' not found in files map"
600        )));
601    }
602
603    // Create loader with virtual filesystem
604    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
605
606    // Load from entry point
607    let load_result = match loader.load(Path::new(entry_point)) {
608        Ok(result) => result,
609        Err(e) => {
610            let result = ValidationResult {
611                valid: false,
612                errors: vec![Error::new(format!("Load error: {e}"))],
613            };
614            return to_js(&result);
615        }
616    };
617
618    // Check for parse errors first (preserves detailed per-error line info)
619    let parse_errors = load_errors_to_errors(&load_result);
620    if !parse_errors.is_empty() {
621        let result = ValidationResult {
622            valid: false,
623            errors: parse_errors,
624        };
625        return to_js(&result);
626    }
627
628    // Run the shared processing pipeline:
629    // sort → synth-plugins → Early validation → book → regular-plugins → Late validation → finalize
630    let options = LoadOptions {
631        validate: true,
632        ..Default::default()
633    };
634
635    let ledger = match process(load_result, &options) {
636        Ok(ledger) => ledger,
637        Err(e) => {
638            let result = ValidationResult {
639                valid: false,
640                errors: vec![Error::new(format!("Processing error: {e}"))],
641            };
642            return to_js(&result);
643        }
644    };
645
646    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
647
648    let result = ValidationResult {
649        // Warnings do not invalidate a ledger; only actual errors do.
650        valid: !has_fatal(&errors),
651        errors,
652    };
653    to_js(&result)
654}
655
656/// Run a BQL query on multiple Beancount files.
657///
658/// Similar to `query`, but accepts multiple files with include resolution.
659///
660/// Note: Glob patterns in `include` directives are not supported in multi-file mode
661/// since there is no real filesystem to enumerate. Use explicit file paths instead.
662#[wasm_bindgen(js_name = "queryMultiFile")]
663pub fn query_multi_file(
664    files: JsValue,
665    entry_point: &str,
666    query_str: &str,
667) -> Result<JsValue, JsError> {
668    use rustledger_booking::merge_with_padding;
669    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
670    use rustledger_query::{Executor, parse as parse_query};
671
672    // Parse the JavaScript object to a HashMap
673    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
674        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
675
676    if file_map.is_empty() {
677        return Err(JsError::new("Files map cannot be empty"));
678    }
679
680    // Create virtual filesystem with all files
681    let vfs = VirtualFileSystem::from_files(file_map);
682
683    // Check entry point exists using VFS path normalization
684    if !vfs.exists(Path::new(entry_point)) {
685        return Err(JsError::new(&format!(
686            "Entry point '{entry_point}' not found in files map"
687        )));
688    }
689
690    // Create loader with virtual filesystem
691    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
692
693    // Load from entry point
694    let load_result = match loader.load(Path::new(entry_point)) {
695        Ok(result) => result,
696        Err(e) => {
697            let result = QueryResult {
698                columns: Vec::new(),
699                rows: Vec::new(),
700                errors: vec![Error::new(format!("Load error: {e}"))],
701            };
702            return to_js(&result);
703        }
704    };
705
706    // Check for parse errors first (preserves detailed per-error line info)
707    let parse_errors = load_errors_to_errors(&load_result);
708    if !parse_errors.is_empty() {
709        let result = QueryResult {
710            columns: Vec::new(),
711            rows: Vec::new(),
712            errors: parse_errors,
713        };
714        return to_js(&result);
715    }
716
717    // Run the shared processing pipeline (queries skip validation):
718    // sort → synth-plugins → book → regular-plugins → finalize
719    let options = LoadOptions {
720        validate: false,
721        ..Default::default()
722    };
723
724    let ledger = match process(load_result, &options) {
725        Ok(ledger) => ledger,
726        Err(e) => {
727            let result = QueryResult {
728                columns: Vec::new(),
729                rows: Vec::new(),
730                errors: vec![Error::new(format!("Processing error: {e}"))],
731            };
732            return to_js(&result);
733        }
734    };
735
736    // Only abort on actual errors, not warnings (matching CLI query behavior)
737    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
738    let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
739    if has_errors {
740        let result = QueryResult {
741            columns: Vec::new(),
742            rows: Vec::new(),
743            errors,
744        };
745        return to_js(&result);
746    }
747
748    // Merge pad-synthesized transactions into the directive stream
749    // (matching CLI query pipeline). See `wasm::query` above for the
750    // architectural rule.
751    // Grab the configured account types before `ledger` fields are moved.
752    let account_types = ledger.options.to_account_types();
753    let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
754    let directives = merge_with_padding(&booked_directives);
755
756    // Parse the query
757    let query = match parse_query(query_str) {
758        Ok(q) => q,
759        Err(e) => {
760            let result = QueryResult {
761                columns: Vec::new(),
762                rows: Vec::new(),
763                errors: vec![Error::new(e.to_string())],
764            };
765            return to_js(&result);
766        }
767    };
768
769    // Execute query
770    let mut executor = Executor::new(&directives);
771    executor.set_account_types(account_types);
772    match executor.execute(&query) {
773        Ok(result) => {
774            let rows: Vec<Vec<_>> = result
775                .rows
776                .iter()
777                .map(|row| row.iter().map(value_to_cell).collect())
778                .collect();
779
780            let query_result = QueryResult {
781                columns: result.columns,
782                rows,
783                errors: Vec::new(),
784            };
785            to_js(&query_result)
786        }
787        Err(e) => {
788            let result = QueryResult {
789                columns: Vec::new(),
790                rows: Vec::new(),
791                errors: vec![Error::new(format!("Query execution error: {e}"))],
792            };
793            to_js(&result)
794        }
795    }
796}
797
798/// Compute a SHA-256 fingerprint of one or more source strings.
799///
800/// Returns the fingerprint as a lowercase hex string. Store this value
801/// alongside serialized ledger bytes and compare on subsequent loads to
802/// detect whether the source has changed.
803///
804/// Each string is separated by a NUL byte before hashing so that
805/// `["ab", "c"]` produces a different fingerprint from `["a", "bc"]`.
806///
807/// The fingerprint is order-sensitive: `["a", "b"]` hashes differently
808/// from `["b", "a"]`. Callers using an unordered collection should sort
809/// by filename first for deterministic results.
810#[wasm_bindgen(js_name = "hashSources")]
811#[allow(clippy::needless_pass_by_value)] // wasm-bindgen requires owned Vec<String>
812pub fn hash_sources(sources: Vec<String>) -> String {
813    let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
814    crate::cache::hash_sources(&refs)
815}