Skip to content

Instantly share code, notes, and snippets.

@vindard
Created June 16, 2026 12:38
Show Gist options
  • Select an option

  • Save vindard/c3097231bcf6c834e564900f65157785 to your computer and use it in GitHub Desktop.

Select an option

Save vindard/c3097231bcf6c834e564900f65157785 to your computer and use it in GitHub Desktop.
Cala (galoymoney/cala) Node.js bindings — historical reference + fork revival recipe (removed in PR #615, commit a5a145c7, 2025-11-22)

Cala Node.js bindings — historical reference

This documents the now-removed Node.js bindings to cala-ledger. It is not present on main anymore; this is a reference for anyone forking the repo who wants to bring them back.

What we used to do

Cala-ledger is a Rust library. To make it consumable from a Node.js process we maintained a napi-rs wrapper crate (cala-nodejs/) that compiled the Rust ledger into a native Node addon and shipped it on npm as @galoymoney/cala-ledger. A TypeScript example app under examples/nodejs/ exercised the full surface end to end, and a BATS test (bats/nodejs-examples.bats) ran that example in CI to keep the bindings honest.

The high-level shape from a consumer's perspective:

import { CalaLedger } from "@galoymoney/cala-ledger";

const cala = await CalaLedger.connect({
  pgCon: "postgres://user:password@localhost:5433/pg",
  outbox: { enabled: true },
});

const account = await cala.accounts().create({ name, code, metadata });
const journal = await cala.journals().create({ name, code, description });
const tmpl    = await cala.txTemplates().create({ code, entries, transaction, params });
const tx      = await cala.transactions().post(tmpl.values().code, params);

Under the hood cala-nodejs/src/lib.rs exposed these modules through #[napi]-annotated wrappers around the Rust types:

  • ledgerCalaLedger::connect, accounts(), journals(), txTemplates(), transactions(), awaitOutboxServer()
  • account — create / list (paginated via query.rs cursors)
  • journal — create / findByCode
  • tx_template — create / findByCode, including NewTxTemplateEntryValues, NewTxTemplateTransactionValues, NewParamDefinitionValues, ParamDataTypeValues
  • transactionpost, findById, findByExternalId
  • query — shared cursor / pagination plumbing
  • generic_errorgeneric_napi_error helper that mapped any cala_ledger error into a napi::Error

Latest state before removal

Final commit on the cala-nodejs/ tree was 206d0086 feat(nodejs): bindings for transactions (#578). After that, only dependabot bumps landed:

  • 195bf1a0 — napi-build 2.2.3 → 2.2.4
  • 801fab5f — napi 3.3.0 → 3.4.0
  • 12f05b7e — earlier major bump moving cala-nodejs onto napi 3.x (#554)

At the time of removal cala-nodejs/Cargo.toml was:

[package]
name = "galoymoney_cala-ledger"
edition = "2021"
license = "Apache-2.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
cala-types       = { workspace = true }
cala-ledger      = { workspace = true }
cel-interpreter  = { workspace = true }
napi             = { version = "3.4.0", default-features = false, features = ["tokio_rt", "serde-json"] }
napi-derive      = "3.2.5"

[build-dependencies]
napi-build = "2.2.4"

package.json published @galoymoney/cala-ledger with a napi block and prebuilt artifacts under cala-nodejs/npm/{darwin-x64,linux-x64-gnu,win32-x64-msvc}/.

The example app in examples/nodejs/ consumed the local build via "@galoymoney/cala-ledger": "file:../../cala-nodejs" and was driven by tsx src/index.ts.

The Makefile entry points were:

update-lib-in-nodejs-example:
	cd cala-nodejs && yarn && SQLX_OFFLINE=true yarn build
	cd examples/nodejs && rm -rf ./node_modules && yarn install

re-run-nodejs-example: clean-deps start-deps
	sleep 2
	cd examples/nodejs && yarn run start

build-nodejs-bindings:
	cd cala-nodejs && yarn && SQLX_OFFLINE=true yarn build
	cd examples/nodejs && yarn install

e2e: clean-deps start-deps build build-nodejs-bindings
	bats -t bats

Removal

PR #615 — chore: remove js integration (commit a5a145c7, authored by nicolasburtey, merged 2025-11-22).

Browse the repo at the last commit before removal (everything still in place): b29d4b93 — in particular cala-nodejs/ and examples/nodejs/.

That single commit deleted:

  • cala-nodejs/ (the whole crate, including .yarn/, npm/<triple>/, __test__/, src/)
  • examples/nodejs/
  • bats/nodejs-examples.bats and the related helpers in bats/helpers.bash
  • The cala-nodejs member entry from the workspace Cargo.toml
  • The build-nodejs-bindings, update-lib-in-nodejs-example, and re-run-nodejs-example Makefile targets, and the build-nodejs-bindings step from e2e
  • Related docker-compose.yml, root yarn.lock, and node_modules/.yarn-integrity leftovers

Net diff: 44 files, +1 / −6762.

Bringing them back in a fork — conceptual recipe

This is the recipe, not a turnkey patch. Concrete code can be lifted directly from the parent of the removal commit: git show a5a145c7^:<path>.

  1. Restore the crate. Recreate cala-nodejs/ from a5a145c7^. The tree is self-contained: Cargo.toml, build.rs, src/, package.json, index.js/index.d.ts (generated by napi build, but the checked-in copies work as a starting point), npm/<triple>/ stubs for prebuilt artifacts, __test__/index.spec.mjs.
  2. Re-add it to the workspace. Put "cala-nodejs" back in the top-level Cargo.toml [workspace] members.
  3. Bump napi to current. napi, napi-derive, and napi-build move fast. Start from the versions in the restored Cargo.toml (3.4.0 / 3.2.5 / 2.2.4 as of 2025-11), then upgrade. If the upstream cala-ledger Rust API has drifted since a5a145c7^, the failing call sites will be in cala-nodejs/src/{ledger,account,journal,tx_template,transaction}/mod.rs — each is a thin #[napi] wrapper, so fixes are mechanical: match the new Rust signatures and re-emit them through napi types.
  4. Restore the example + BATS. Drop examples/nodejs/ and bats/nodejs-examples.bats back in. The example doubles as a smoke test of the whole surface (account create/list, journal create, tx_template with DR/CR entries, transaction post + lookup by id + by external id).
  5. Restore the Makefile targets (build-nodejs-bindings, update-lib-in-nodejs-example, re-run-nodejs-example) and wire build-nodejs-bindings back into e2e if you want CI parity.
  6. Publishing. The npm package was @galoymoney/cala-ledger with per-triple sub-packages under cala-nodejs/npm/. A fork will want to rename the scope and re-run napi prepublish -t npm to regenerate those stubs. The prepublishOnly script in package.json already does this.
  7. Sanity check. make build-nodejs-bindings followed by make e2e reproduces the original CI signal.

Things to watch out for if you do this:

  • SQLX_OFFLINE=true is load-bearing for the napi build — the cala-ledger crate uses compile-time sqlx checks, and the napi build runs from a different cwd than the usual cargo invocation. The original Makefile always set it explicitly.
  • crate-type = ["cdylib"] — required for napi. Don't add rlib unless you also want to depend on the crate from other Rust code in the workspace.
  • tokio_rt + serde-json napi features — the wrappers assume both are on; turning either off will break the async connect() and the JSON metadata paths respectively.
  • Outbox server lifecycle. The example called await cala.awaitOutboxServer() (effectively await_outbox_handle) before exit to drain the outbox. If you skip this in your own consumer, events can be dropped on shutdown.
  • Reverts in history. The tx_templates work landed and was reverted three times (#553→#564, #565→#566, #567 reverted, then finally #29edaa26 / #578) before settling. If you cherry-pick instead of resetting to a5a145c7^, prefer the final landed versions.

Atomicity with non-Cala writes — what to expect if you revive these bindings

The intention

The natural follow-up question once the napi bindings exist: "Can my JS code do someDbTx.begin()cala.transactions.post(...)myModel.update(...)someDbTx.commit() and have all of it either land together or roll back together?"

It is a fair question. Real applications that use a ledger almost always have non-ledger state to write in the same logical unit of work — order status, user records, audit rows, outbound message queues. If the ledger write succeeds but the order write fails (or vice versa), reconciling state afterwards is painful enough that systems grow elaborate compensation scaffolding to deal with it.

The short answer is no, not directly, and the reason is architectural, not a missing feature in the bindings.

Why the bindings can't deliver this

Two structural constraints stack:

  1. Postgres transactions are bound to a single connection. There is no way for two separate connections to share an in-flight transaction. BEGIN on connection A and INSERT on connection B will be two independent transactions, period. The transaction's snapshot, locks, and write-ahead state are all tied to a single backend process on the server side. This is a Postgres protocol-level fact, not a driver quirk.

  2. Cala-ledger and your JS DB library are two separate connection pools, in two separate language runtimes, talking to Postgres independently. Cala uses sqlx (Rust). Your JS code uses pg / knex / prisma / drizzle / typeorm — whichever. Each has its own pool. There is no shared connection to share a transaction over.

The runtime picture:

Node process
├── JS heap
│   └── pg pool (knex/prisma/…) ──┐
│                                  ├── two independent sets of TCP sockets → Postgres
└── native addon (Rust, via napi)  │
    └── sqlx PgPool ──────────────┘

When your Node app calls CalaLedger.connect({ pgCon: "postgres://..." }), the call crosses the napi boundary into Rust. The Rust side asks sqlx to build a PgPool from that connection string. That pool lives entirely inside the native addon's memory. Your JS-side library — invariably built on pg under the hood — opens its own pool of sockets, managed by libuv in the Node heap. Postgres doesn't know they're "the same app"; it just sees N independent client sessions.

Even if you exposed Cala's AtomicOperation / DbOpWithTime to JS through the napi bindings (the natural extension once you want multi-call Cala atomicity — see below), you'd still only have atomicity within Cala. The handle wraps a sqlx Transaction<'_, Postgres> over a sqlx-owned connection. There is no way to hand a pg.PoolClient to Rust and have sqlx adopt it — they don't share a wire-protocol parser, they don't share a connection state machine, and the napi boundary has nothing useful to pass across anyway. Even at the socket level it would be hopeless: two different libraries cannot share an open Postgres connection without corrupting each other's protocol state.

Counter-example: lana-bank uses the same Cala crate without this problem

The constraints above are about the napi setup, not about cala-ledger itself. GaloyMoney/lana-bank is a Rust application that consumes cala-ledger as a regular Rust crate, and it bundles arbitrary domain writes with Cala writes atomically all day long. Looking at how it does this is the cleanest way to understand what the napi boundary takes away.

In core/credit/src/credit_facility/jobs/activate.rs:144:

let mut op = current_job.begin_op().await?;        // one sqlx tx, one connection
self.credit_facilities
    .activate_in_op(&mut op, ...)                  // lana's own repo writes on `op`
    .await?;
Ok(JobCompletion::CompleteWithOp(op))              // job framework commits `op`

And inside, e.g., core/accounting/src/chart_of_accounts/ledger/mod.rs:379:

self.cala
    .ledger()
    .post_transaction_in_op(op, tx_id, &template_code, params)  // SAME op
    .await?;

The same op (an es_entity DbOp<'_> wrapping a single sqlx::Transaction<'_, Postgres> on a single connection from one pool) is threaded through:

  1. lana's own domain repos (credit_facility, deposits, chart-of-accounts, …)
  2. Cala's *_in_op calls
  3. The job framework's outbox / event writes
  4. op.commit() to finalize everything atomically

Three conditions make this work — and they are exactly the Medici v5 conditions translated to Postgres:

  1. Same process. No FFI, no native addon, no second runtime.
  2. Same driver. Both lana and cala use sqlx. They speak the same connection state machine.
  3. Same pool. lana doesn't build its own PgPool; it shares the one Cala built (passed in at bootstrap). begin_op() borrows a connection from that pool and starts a sqlx transaction; Cala's *_in_op methods accept the resulting AtomicOperation and write through the same connection.

So in lana: atomic ledger write + atomic domain write + atomic outbox event = one Postgres transaction on one connection. The Medici v5 promise, fully delivered, in pure Rust, against Postgres.

The napi bindings reintroduce a language boundary, and with it a second pool — and that single architectural fact is what the rest of this section's workarounds (Cala-first + reconcile, outbox, collapse into Cala, 2PC) are dancing around. The cala-ledger crate doesn't need any new features to support atomic cross-system writes; it just needs to be the only thing holding the database connection.

What you can expose through the bindings

Atomicity across multiple Cala calls is straightforward to add and worth doing. The underlying Rust crate already supports it:

  • CalaLedger::begin_operation() returns a DbOpWithTime
  • Every mutating method has an _in_op variant (post_transaction_in_op, accounts create_in_op, journals create_in_op, …)
  • db.commit() finalizes; dropping rolls back

The shape of what bindings would need to add:

const op = await cala.beginOperation();
try {
  await cala.accounts().createInOp(op, newAccount);
  await cala.transactions().postInOp(op, tmplCode, params);
  await op.commit();
} catch (e) {
  await op.rollback();
  throw e;
}

A #[napi] handle around DbOpWithTime plus *InOp variants of the existing methods. Genuinely useful — it gives you "create the account and post the opening balance together, or not at all" — but it does not bridge to your JS-side DB transaction. It just makes multiple Cala calls atomic with each other.

Options for "bundle Cala with arbitrary JS writes"

In rough order of how-often-they're-the-right-answer:

1. Cala-first + idempotency + reconcile (default choice)

Post the Cala transaction with a deterministic external_id that you also store on the JS side. Then do the JS write referencing that id.

const externalId = uuidv7();        // generated locally, persisted with the unit of work
await cala.transactions().post(tmplCode, { ...params, externalId });
await Order.updateOne({ _id: orderId }, { status: 'paid', calaExternalId: externalId });

If the JS write fails, retry the whole flow — findByExternalId makes the Cala post idempotent. If the process dies between the two writes, a reconciliation job sweeps:

  • JS rows referencing a Cala tx → verify the tx exists; if missing, replay
  • Cala txs without a matching JS row (within some window) → alert / replay JS side

You get eventual consistency on the order of seconds with a small repair surface. This is what most production systems built on Cala end up doing.

2. Outbox (JS-first)

JS-side transaction writes its rows plus an outbox row describing the intended Cala call, atomically:

await db.transaction(async (tx) => {
  await Order.update({ status: 'paid' }, { tx });
  await Outbox.create({
    type: 'cala.post',
    externalId,
    template: tmplCode,
    params,
  }, { tx });
});
// async worker drains outbox → calls cala.transactions.post(...) with externalId

JS-side state is the source of truth. Cala writes happen asynchronously but deterministically via the worker, keyed by externalId so retries are safe. Good fit when:

  • Your JS DB is your system of record for the non-ledger state
  • You don't need the Cala tx id back synchronously in the request that triggered the work
  • The latency budget tolerates "ledger lags JS by N ms"

3. Collapse the boundary — push the JS data into Cala

Often the cleanest answer when the "JS side" data is small or audit-shaped. Cala transactions have metadata, correlation_id, external_id, and a description. If your non-ledger write is "stamp this order id on the movement", just put the order id in correlation_id and stop maintaining a second write entirely.

Inverse direction: Cala publishes outbox events from the Rust side. Your JS-side state can be a projection of those events instead of an independent write. You write to Cala synchronously; the JS DB catches up. This eliminates the dual-write problem by removing the dual write.

4. Two-phase commit (only if you really mean it)

Postgres supports it natively: PREPARE TRANSACTION 'gid' on each connection, then a coordinator issues COMMIT PREPARED or ROLLBACK PREPARED. True distributed-transaction atomicity. Cost:

  • Fork or extend Cala so the AtomicOperation can be prepared (not committed) and the gid surfaced to the coordinator
  • Coordinator must durably log gids before the commit phase or lose them on crash
  • max_prepared_transactions must be raised in Postgres config
  • Orphaned prepared transactions hold locks indefinitely; you need a janitor process to clean them up

It is real atomicity. It is also operationally heavy and almost never justified for application code. Reserve for genuinely catastrophic inconsistency-cost scenarios.

Real-world comparison: Medici v5 (and why it can do what Cala can't)

Worth knowing about because it's the cleanest expression of "ledger participates in caller's transaction" the JS world has — and it makes the structural reason Cala can't do the same thing concrete.

Medici is a double-entry ledger for Node.js built on Mongoose / MongoDB. v4 had no atomicity — a journal was N child documents written non-atomically, with an approved: false → true flip after all lines landed as a kludge to hide partial writes. A crash between insert and approve could leave phantom journals.

v5 deleted the kludge and adopted MongoDB 4.0+ multi-document transactions. The entire mechanism is: every Medici write method accepts a ClientSession. That's it. The library ships a mongoTransaction helper whose full source at v5.0.0 is:

export async function mongoTransaction<T = unknown>(
  fn: (session: ClientSession) => Promise<T>
) {
  return connection.transaction(fn);
}

That is the whole contribution. connection.transaction is Mongoose's built-in transaction helper. The session it hands you is a vanilla MongoDB ClientSession — the same type any Mongoose write accepts via { session }.

What it looks like in use

import { Book, mongoTransaction } from 'medici';

const ledger = new Book('mainLedger');

await mongoTransaction(async (session) => {
  // Ledger write
  await ledger
    .entry('Payment for Order #42')
    .debit(`Accounts:${userId}`, 100)
    .credit('Revenue', 100)
    .commit({ session });

  // Arbitrary Mongoose write — same atomic unit, no special API needed
  await Order.updateOne(
    { _id: orderId },
    { status: 'paid', paidAt: new Date() },
    { session },
  );

  await Outbox.create([{ event: 'order.paid', orderId }], { session });
});

Throw inside the callback → everything rolls back, including the journal. Return → everything commits. No outbox, no saga, no idempotency keys, no reconciliation worker. The ledger, the order, and the outbox row are one MongoDB transaction.

Why it works for them

Three things stack:

  1. Same process. Medici is a Node library. No language boundary.
  2. Same driver. Medici uses the MongoDB Node driver under the hood. The caller uses Mongoose, which is built on the same driver. They share the wire-protocol implementation.
  3. Same connection pool. Both Medici and your Mongoose models read from mongoose.connection. A ClientSession is a handle into that pool. Threading it through is genuinely free.

The session is a portable, driver-level concept that any caller can opt into.

v5 caveats worth knowing

  • MongoDB multi-document transactions require a replica set (4.0+). Standalone mongod rejects startTransaction.
  • You must call initModels() after connecting and before opening transactions — collections can't be created inside a transaction, so Medici materializes them upfront. Skipping this trips MongoDB's "Unable to read from a snapshot due to pending collection catalog changes" error on the first tx.
  • Don't use readConcern: 'majority'. The Medici README carries a stern warning about negative balances and missing-credit symptoms when this is enabled under load on replica sets. Stick to local.
  • Lock narrow, lock last. book.writelockAccounts([...], { session }) takes an explicit row-lock on the accounts you read inside the transaction. Without it, two concurrent withdrawals can both observe a positive balance and both succeed. Lock the per-user / per-wallet account, not the parent Assets account, and lock late in the callback to minimize the contention window.
  • Mongoose middlewares don't fire on Medici models in v5 — the library talks to the driver directly, bypassing Mongoose's hook machinery.

Why Cala-on-Postgres structurally can't do the equivalent

The Medici story is "we threaded the driver's transaction handle through our API." For Cala to do the same thing, you'd need:

  • One process containing both the Cala-ledger code and the caller's ORM/DB code. The napi bindings split this across Rust and JS runtimes — the language boundary isn't fatal on its own, but the next two are.
  • One Postgres driver that both Cala and the caller use. Cala uses sqlx (Rust). JS callers use pg / knex / prisma / drizzle / typeorm — none of which speak sqlx's connection state. You can't hand a pg.PoolClient to Rust and have sqlx adopt it. Even within the Rust ecosystem, a sqlx Transaction<'_, Postgres> and a tokio-postgres transaction are not interchangeable.
  • One connection pool shared between them. Postgres transactions are per-connection. There is no ClientSession-like portable handle in the Postgres protocol that a second connection can adopt mid-flight.

MongoDB's ClientSession is the magic ingredient: it's a logical session the server tracks, decoupled from any single connection. Postgres has nothing equivalent. The closest is PREPARE TRANSACTION, which is 2PC — useful for distributed commit, not for "let me hand you my open transaction."

Practically: if you want a Medici-like ergonomic story for Cala, you have to either (a) replace the JS DB library with sqlx on the Rust side and expose it through bindings (which is just "do everything in Rust with a JS facade"), or (b) accept the eventual-consistency patterns above. The napi boundary on its own won't bridge the gap.

And, again — option (a) is exactly the lana-bank shape. Same crate, same Postgres, three-conditions-satisfied, atomic cross-system writes just work. The bindings are not what unlocks Medici-grade ergonomics; the bindings are what prevents them, by inserting a second pool.

Summary for a future revival

If you bring the bindings back and someone asks for atomic Cala + JS writes:

Want Feasible How
Atomic across multiple Cala calls Yes, additive bindings work Expose beginOperation() + *InOp variants over DbOpWithTime
Atomic across Cala + JS writes via shared tx handle No Two pools, two drivers, two runtimes — Postgres can't share an open tx
Atomic across Cala + non-Cala writes from a pure Rust app Yes, already works One sqlx pool, one DbOp threaded through both Cala's *_in_op and your own repos — this is what lana-bank does
"Looks atomic" in normal operation, repaired on crash Yes Cala-first + externalId + reconcile job
JS DB owns truth, Cala catches up Yes Outbox pattern with worker calling Cala by externalId
Remove the dual-write entirely Often yes Stuff data into Cala metadata / correlation_id, or project from Cala's outbox into JS
Hard distributed atomicity Yes, expensive Postgres 2PC, requires Cala fork + coordinator + janitor

The Medici comparison is the right mental model for understanding why the napi route is not Medici v5 — and for setting consumer expectations before they design around a guarantee the bindings can't structurally offer.

Binding a Rust library to other languages — a practical guide

A conceptual companion to cala-nodejs-bindings.md. The cala-nodejs crate was one concrete instance of a general pattern: take a Rust library with a clean public API, wrap it in a thin shim, and expose it as a native module to a host language runtime. This doc surveys the realistic options, what they buy you, and what they cost — framed around a project shaped like cala-ledger (async Tokio, SQLx-backed, structured domain types, error enums).

Why Rust is uniquely well-suited here

Most languages can't be the core in a polyglot library. Java, Python, Node, Go, C# all drag a runtime with them — a GC, a scheduler, an interpreter. "Embedding" any of them really means embedding their VM. C and C++ can be embedded, but C++ brings its own friction (exception ABI, name mangling, std types across DLL boundaries), and C is the baseline — which is unsafe by construction.

Rust occupies a narrow but extremely valuable spot: it produces a plain shared object that speaks the C ABI with no runtime tax, while keeping the high-level features (types, traits, async, sum types, ownership) that make the wrapper crate cheap to write and hard to mess up. The properties that matter:

  • No mandatory runtime. A cdylib is just code + static data. Nothing starts a GC, nothing schedules green threads, nothing initializes a global interpreter. The host process keeps its runtime; Rust slots in beside it. You cannot do this with Go (cgo + Go scheduler), the JVM, or any managed language without dragging the whole machine along.
  • Ownership maps onto host handle lifecycles. Box<T> becomes "the host owns this opaque pointer." &T / &mut T become "borrowed for this call." Move semantics naturally translate to "transfer ownership across the boundary." Generators like napi-rs, PyO3, and uniffi all lean on this — the binding for fn consume(self) is structurally different from fn read(&self) and you don't have to design that distinction yourself.
  • Memory safety without a second GC. The host already has its own collector or refcounter. A Rust core doesn't add another one to coordinate with. Drop runs deterministically when the host's finalizer or context manager fires — no GC tuning, no finalizer ordering surprises.
  • Errors as values cross boundaries cleanly. Result<T, E> maps trivially onto host exceptions or Result-likes. There is no cross-DLL exception ABI to negotiate — the C++ pain point disappears. Every binding tool does this mapping in one helper; cala-nodejs's generic_napi_error was effectively a single line of logic.
  • Send / Sync are checkable at the boundary. Binding generators refuse to expose a !Send handle to a multi-threaded host. Threading bugs that surface as data races in a C binding are caught at compile time in a Rust binding.
  • Async is inert and steerable. Rust futures don't pick a runtime. The wrapper picks one (almost always Tokio) and adapts it to the host's async shape — JS Promise, Python coroutine, Kotlin coroutine. The library author writes async fn once; each wrapper decides how it's driven. Compare Go (you take the Go scheduler or nothing) or Node (libuv or nothing).
  • Procedural macros put the binding next to the code. #[napi] above pub async fn create(...) is the entire binding for that method. No separate IDL file, no codegen step running out of band, no hand-written header drifting from the impl. uniffi and the C-FFI tools (cbindgen) do still need an out-of-band step; the language-native tools mostly don't.
  • Cross-compilation actually works. cargo build --target aarch64-apple-darwin against a Linux host produces a working binary the vast majority of the time. This is what makes per-triple prebuilt npm/pip packages economically possible. The C/C++ equivalent demands per-platform CI with real toolchains; Go's cross-compile is good but loses you cgo, which is most of the binding ecosystem.
  • WebAssembly is a real target, not a science project. wasm32-unknown-unknown is a tier-2 target that genuinely works for pure-compute crates. C/C++ via Emscripten works but ships an entire POSIX shim; managed languages can't get there without shipping their VM as Wasm.
  • Zero-cost abstractions mean the wrapper isn't a tax. Iterators, ?, async, generics — all of it compiles down. The wrapper reads like ergonomic Rust and runs like hand-written C. No "we wrote it nicely but had to rewrite the hot path in C" stage.
  • Safe-by-default flips the contract. A C library's docs say "don't pass null, don't double-free, don't read after free." A Rust library encodes those conditions in the types — the safe surface can't violate them. The unsafe surface area shrinks to the binding shim itself, which is small and reviewable.

The cumulative effect: one high-level Rust library can ship as a native Node module, a Python wheel, a Kotlin AAR, a Swift package, and a Wasm bundle — and the wrappers are thin enough that maintaining all of them isn't crazy. None of the individual properties is novel on its own. The conjunction — embeddable, safe, ergonomic, multi-target, no runtime — is what makes Rust uniquely well-suited to being the polyglot core.

Mental model

Every binding crate is doing three jobs:

  1. Translate values. Map host-language values (JS objects, Python dicts, etc.) into the Rust types your library wants, and vice versa.
  2. Translate errors. Map your Result<_, MyError> into whatever exception / Result shape the host expects.
  3. Translate execution. Bridge between the host's event model and the Rust async runtime — usually Tokio.

If you keep those three concerns isolated in the wrapper crate, the host language is almost a swap-out detail. cala-nodejs did exactly this: its generic_error.rs handled (2), tokio_rt napi feature handled (3), and each src/<entity>/mod.rs was a thin (1) layer over cala_ledger.

The realistic options

napi-rs — Node.js

  • What it is. Procedural macros that generate N-API native modules from #[napi]-annotated Rust. Ships a CLI (napi build, napi prepublish) that produces prebuilt binaries per <os>-<arch> triple and stitches them into a single npm package.
  • Async story. First-class. The tokio_rt feature spawns an embedded Tokio runtime; async fn on a #[napi] impl returns a JS Promise.
  • When to pick it. TypeScript consumers, you want a published npm package with prebuilt binaries, you can tolerate per-platform CI.
  • Watch out for. N-API ABI version skew across Node releases; crate-type = ["cdylib"] is mandatory; CI matrix grows linearly with the triples you publish.

PyO3 + maturin — Python

  • What it is. PyO3 provides the macros (#[pyclass], #[pymethods], #[pyfunction]); maturin is the build tool that produces .whl files and can publish to PyPI.
  • Async story. Use pyo3-asyncio to bridge Tokio futures into asyncio coroutines. Works, but you must pick one async runtime on the Python side and stay consistent.
  • When to pick it. Data / ML / scripting consumers, you want a pip install story, you don't mind learning the GIL rules (PyO3 hides most of them but not all).
  • Watch out for. GIL acquisition around long Rust calls — release it with Python::allow_threads for blocking work; mismatched abi3 vs. per-version wheels.

uniffi — multi-language (Kotlin, Swift, Python, Ruby, …)

  • What it is. Mozilla's tool: you write a .udl interface (or use proc-macros) describing your API, uniffi generates both the Rust scaffolding and bindings for each target language.
  • Async story. Supported for the major targets but more limited than language-native tools — async traits, cancellation, and streaming have rough edges.
  • When to pick it. Mobile (Kotlin + Swift sharing one Rust core) is the killer use case; you'd otherwise be writing two wrapper crates.
  • Watch out for. UDL is a lowest-common-denominator IDL; rich Rust enums and lifetimes don't map cleanly. Best when your public API is already shaped like "structs with fields and methods that take primitives + structs."

wasm-bindgen — browsers / Wasm runtimes

  • What it is. Compile your crate to wasm32-unknown-unknown (or wasm32-wasi), let wasm-bindgen generate the JS glue. wasm-pack bundles it into something npm can consume.
  • Async story. wasm-bindgen-futures maps Rust Futures to JS Promises. No Tokio in the browser — you give up the threaded runtime and most of std::net / file I/O.
  • When to pick it. Pure-compute libraries (parsers, cryptography, CEL-style expression evaluation, ledger math) that need to run in the browser. Bad fit for anything that talks to a database — cala-ledger itself couldn't run this way; only sub-crates like cala-cel-interpreter could.
  • Watch out for. Bundle size; no native threads without SharedArrayBuffer + COOP/COEP headers; SQLx and other native deps will refuse to compile.

Raw FFI — C ABI as the lingua franca

  • What it is. #[no_mangle] pub extern "C" fn ..., a manually written cala.h, and let every language with a C FFI (Ruby, Go via cgo, C++, Lua, Erlang NIFs, …) call in.
  • Async story. You build it. Usually means exposing a callback-based or polling API on top of a Tokio runtime you manage.
  • When to pick it. The host language has no first-class Rust wrapper and you don't want to maintain N wrappers — or you're shipping a .so/.dylib/.dll as the distribution artifact.
  • Watch out for. You own all the safety. cbindgen helps generate the header; Box::into_raw / Box::from_raw lifetime discipline is unforgiving; panics across the FFI boundary are UB unless caught.

gRPC / OpenAPI — "binding" via process boundary

  • What it is. Don't link at all. Run cala as a server (cala-server already does this), define a Protobuf or OpenAPI schema, generate clients in every language for free.
  • When to pick it. Multi-process anyway, polyglot consumers, you don't need sub-millisecond latency, you'd rather pay an RPC hop than maintain N wrapper crates.
  • Watch out for. Now you're shipping a service, not a library; schema versioning is its own problem; in-process invariants (one Tokio runtime, one DB pool) become deployment concerns.

Picking between them

A rough decision tree:

  • One target language, native feel matters → language-specific tool (napi-rs / PyO3).
  • Mobile (Kotlin + Swift) → uniffi.
  • Browser → wasm-bindgen, but only for pure-compute sub-crates.
  • Many languages, none of them above → C FFI + cbindgen, or just run the service and generate clients.
  • You already run as a service → gRPC; skip in-process bindings.

Cross-cutting practical notes

These bit cala-nodejs in real ways and will bite any binding crate:

  • Async runtime ownership. Decide once where Tokio lives. In-process bindings should embed it (napi-rs's tokio_rt feature, PyO3's pyo3-asyncio::tokio). Never let two binding crates both spin up runtimes against the same DB pool.
  • crate-type = ["cdylib"]. Required for every native-module approach. If you also need to consume the wrapper from Rust, add "rlib" alongside it, not instead.
  • Workspace member, not standalone repo. Keeping the wrapper in the same workspace as the core library means breaking API changes show up at compile time, not after publish. cala did this; uniffi-style multi-repo setups regret it later.
  • SQLx and compile-time SQL checks. If the core uses sqlx::query!, the wrapper build will try to hit the dev database too unless you set SQLX_OFFLINE=true and check in .sqlx/. This was load-bearing in cala-nodejs's Makefile and the same trap applies to any wrapper.
  • Error mapping is one helper, not N. A single fn to_host_error(e: MyError) -> HostError keeps the wrapper modules short and consistent. cala-nodejs's generic_napi_error was exactly that.
  • Pagination / cursors. Rust cursor types (opaque tokens) translate to host languages cleanly if you serialize them as base64 strings at the boundary. Trying to expose the typed cursor struct directly is almost always more pain than it's worth.
  • Domain newtypes vs. host primitives. Uuid, Decimal, DateTime<Utc>, JSON values — pick a single string/JSON representation per type and apply it everywhere. Inconsistent serialization is the #1 source of binding bugs.
  • Drop semantics. Most host languages have GC, your Rust types often hold sockets / file handles / runtime handles. Provide an explicit close() / shutdown() and document that relying on the GC is undefined-timing.
  • Publishing. Per-triple prebuilt binaries (napi-rs, maturin, wasm-pack) eliminate the "user must have a Rust toolchain" UX cliff. Set up the CI matrix early — it's the part that rots if you defer it.
  • Versioning. Tie wrapper versions to the core library version (cala used a single workspace version). Decoupling them invites "wrapper 0.4 against core 0.7" support tickets.
  • Testing. Run the host-language test suite against a real built binary in CI. cala-nodejs did this via BATS driving the TS example. Unit-testing the wrapper crate in Rust alone misses every binding-layer bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment