How-to Guides¶
Adding a new conversion template¶
No code changes required — adding a template is purely configuration work. The full config schema, expression reference, and field-by-field walkthrough live in the user guide: Templates, Expressions and Config schema. The short skeleton:
"source_to_target": {
"data_dir": "../data/source_to_target",
"file_pattern_in": ".csv",
"input_schema": { "$date": "...", "$ticker": "...", /* ... */ },
"row_rules": [ { "when": "...", "rows": [ { "$action": "'BUY'" } ] } ],
"output_schema": { "date": "$date", /* ... */ }
}
Dev-only tips (not in the user guide):
- Start with
row_rules_debug_missing: true+ run with--debugto surface rows that match no rule. - For paired-row sources (one row references another via an id),
use
pre_pass+LOOKUP(). AnyCoin is the reference template. - Drop a
datasets/<template_id>/{sample.csv, sample.json, sample.expected}triple to wire the template into the regression suite —scripts/test.shpicks it up automatically.
Adding a new built-in function¶
- Define the function in
bxp-core/src/expr.zig: - Find
Parser.evalCall()(called from the parser when a function name is recognized) and add a branch for the new name. - Arguments arrive already evaluated as
Values, and already checked against theFnDocarg table by the centralvalidateArgsdispatcher — so the impl can assume arity andArgKinddomains hold. (The lazy builtinsIF/CASE/IFERRORparse their own arg lists instead.) -
Return a
Valueor propagate an error. -
Add a
FnDocentry co-located with the implementation — follow the── MY_FUNC ──section-header pattern used by the existing built-ins.docs.zigre-exports the catalog automatically; no separate doc file to update. Theargs[].kindentries are whatvalidateArgsenforces at runtime and whatstaticCheckCallsenforces on literals at config-load time. -
Add unit tests inline in
expr.zig:
- Run tests:
Adding a new bridge FFI export¶
The bridge hosts a new-style FFI family — synchronous, in-process C-ABI
exports that link a stateless bxp-core routine directly into the GUI process,
skipping the subprocess spawn. The shipped members are bridge_eval_expr
(in-proc expr validation), bridge_eval_expr_trace (in-proc expr trace),
bridge_inspect (the rest of the stateless surface — docs / config /
list_templates / fetch_template / eval_batch, dispatched by a JSON
request envelope) and bridge_verify_minisign (the updater's signature
check). Every new member follows the conventions below so adding the tenth
export is as mechanical as adding the first.
These conventions cover the stateless in-proc family only. The subprocess-proxy exports (
bridge_run,bridge_run_streaming,bridge_cancel,bridge_ack,bridge_free) keep their own established conventions from the proxy era. A future handle-based family (stateful, e.g. a loaded-config handle) would need a separate convention set — lifecycle, per-handle memory, handle-table thread safety — deliberately out of scope here.
1 — Buffer protocol. Caller supplies the output buffer; the bridge never
mallocs the result and there is no Dart-side free for this family.
| Return | Meaning |
|---|---|
0 |
Success, no payload (valid, nothing to report) |
> 0 |
bytes_written to out_buf — may carry a success or failure payload |
< 0 |
Bridge-level error code (see rule 2) |
A positive return does not automatically mean success: for exports where
error info belongs in the payload (bridge_eval_expr returns
{"error":...,"off":...,"len":...} JSON), the caller always parses out_buf
when bytes_written > 0. What a non-zero payload means is documented per-export
in its doc comment. On overflow the caller retries with a bigger buffer (4 KB
default for expr, 64 KB retry) — mirrors the largeBufSize retry in
BridgeClient.
2 — Error codes. Bridge-level failures (problems originating in the bridge
layer, not in evaluation) are a negative i32 enum:
const BridgeFfiError = enum(i32) {
out_of_memory = -1, // c_allocator can't satisfy the per-call arena
buf_too_small = -2, // out_buf overflowed mid-write; caller retries bigger
invalid_input = -3, // caller's request is malformed: fix the call site
};
Note there is no eval_error code. Evaluation-level failures (syntax error,
unknown function, divide-by-zero) are not a negative code — they return
bytes_written > 0 with a structured JSON payload (rule 4). This was a
deliberate decision: keep negative codes for "your call is broken" and
let payloads carry "the expression is broken, show the user." Any new export
follows the same split.
3 — Memory ownership: caller-owns-everything. Input pointers are owned by
the caller for the call's duration; the bridge must hold no reference past
return. The output buffer is caller-allocated and caller-freed. Internally the
export opens a per-call ArenaAllocator.init(c_allocator) with defer
arena.deinit() — every transient allocation dies at return. No handle table,
no Dart-side bridge_free for this family.
4 — Stateless + thread-safe. Calling bridge_eval_xxx(args) 100× is
semantically identical to calling it once: no loaded configs, cached rows, or
counters survive between calls (the only allowed global is the read-only,
init-once docs catalog). Because there is no shared mutable state and the arena
is per-call, the family is thread-safe without mutexes — safe to call directly
from the Dart main isolate (sub-ms latency, well under one frame budget). If a
future export genuinely needs persistent state, it belongs in the handle-based
family, not here.
5 — UTF-8, length-prefixed strings. Inputs are ptr: [*]const u8, len: u32,
not null-terminated ([*:0]): Dart strings may contain interior \0, the
explicit length skips a strlen, and it stays consistent with the output buffer
protocol. JSON args (e.g. row_headers / row_fields for the trace export)
follow the same shape.
6 — Output JSON shape matches the inspect core contract. A failure payload is
byte-identical to the shape inspect.validateExpr produces (the same one bxp-mcp returns),
so the existing Dart parser handles bridge and subprocess responses
identically — no Dart parser change when wiring a new export:
off / len / suggest are optional (emitted only when the parser pins a token
or has a "did-you-mean" candidate). The trace export instead emits an NDJSON
stream identical to inspect.evalTrace output, where success/failure is read
from the t field of the last line.
Worked reference — the shipped bridge_eval_expr: see
bxp-gui-bridge/src/main.zig (bridge_eval_expr,
writeExprErrorJson, writeStaticErrorJson). Note it does two things beyond a
bare expr.eval: it runs expr.staticCheckCalls after a clean eval to catch
literal-only mistakes the runtime skips (e.g. SPLIT_PART(..., 0)), mirroring
BrokerConfig.validate() so editor-time and Save-time diagnostics agree. The
Dart side lives in
bxp-gui/lib/services/bridge_client.dart.
Any ABI change (signature, new error code) must bump both the bridge export and
its Dart shim in the same commit — there is no auto-versioned compatibility
shim, so a stale .so/.dll against a new GUI silently misbehaves.