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