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.
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:
ledger—CalaLedger::connect,accounts(),journals(),txTemplates(),transactions(),awaitOutboxServer()account— create / list (paginated viaquery.rscursors)journal— create /findByCodetx_template— create /findByCode, includingNewTxTemplateEntryValues,NewTxTemplateTransactionValues,NewParamDefinitionValues,ParamDataTypeValuestransaction—post,findById,findByExternalIdquery— shared cursor / pagination plumbinggeneric_error—generic_napi_errorhelper that mapped anycala_ledgererror into anapi::Error
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.4801fab5f— napi 3.3.0 → 3.4.012f05b7e— 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 batsPR #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.batsand the related helpers inbats/helpers.bash- The
cala-nodejsmember entry from the workspaceCargo.toml - The
build-nodejs-bindings,update-lib-in-nodejs-example, andre-run-nodejs-exampleMakefile targets, and thebuild-nodejs-bindingsstep frome2e - Related
docker-compose.yml, rootyarn.lock, andnode_modules/.yarn-integrityleftovers
Net diff: 44 files, +1 / −6762.
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>.
- Restore the crate. Recreate
cala-nodejs/froma5a145c7^. The tree is self-contained:Cargo.toml,build.rs,src/,package.json,index.js/index.d.ts(generated bynapi build, but the checked-in copies work as a starting point),npm/<triple>/stubs for prebuilt artifacts,__test__/index.spec.mjs. - Re-add it to the workspace. Put
"cala-nodejs"back in the top-levelCargo.toml[workspace] members. - Bump napi to current.
napi,napi-derive, andnapi-buildmove fast. Start from the versions in the restoredCargo.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 sincea5a145c7^, the failing call sites will be incala-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. - Restore the example + BATS. Drop
examples/nodejs/andbats/nodejs-examples.batsback 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). - Restore the Makefile targets (
build-nodejs-bindings,update-lib-in-nodejs-example,re-run-nodejs-example) and wirebuild-nodejs-bindingsback intoe2eif you want CI parity. - Publishing. The npm package was
@galoymoney/cala-ledgerwith per-triple sub-packages undercala-nodejs/npm/. A fork will want to rename the scope and re-runnapi prepublish -t npmto regenerate those stubs. TheprepublishOnlyscript inpackage.jsonalready does this. - Sanity check.
make build-nodejs-bindingsfollowed bymake e2ereproduces the original CI signal.
Things to watch out for if you do this:
SQLX_OFFLINE=trueis load-bearing for the napi build — thecala-ledgercrate 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 addrlibunless you also want to depend on the crate from other Rust code in the workspace.tokio_rt+serde-jsonnapi features — the wrappers assume both are on; turning either off will break the asyncconnect()and the JSON metadata paths respectively.- Outbox server lifecycle. The example called
await cala.awaitOutboxServer()(effectivelyawait_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.
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.
Two structural constraints stack:
-
Postgres transactions are bound to a single connection. There is no way for two separate connections to share an in-flight transaction.
BEGINon connection A andINSERTon 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. -
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.
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:
- lana's own domain repos (credit_facility, deposits, chart-of-accounts, …)
- Cala's
*_in_opcalls - The job framework's outbox / event writes
op.commit()to finalize everything atomically
Three conditions make this work — and they are exactly the Medici v5 conditions translated to Postgres:
- Same process. No FFI, no native addon, no second runtime.
- Same driver. Both lana and cala use sqlx. They speak the same connection state machine.
- 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_opmethods accept the resultingAtomicOperationand 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.
Atomicity across multiple Cala calls is straightforward to add and worth doing. The underlying Rust crate already supports it:
CalaLedger::begin_operation()returns aDbOpWithTime- Every mutating method has an
_in_opvariant (post_transaction_in_op, accountscreate_in_op, journalscreate_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.
In rough order of how-often-they're-the-right-answer:
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.
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 externalIdJS-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"
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.
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
AtomicOperationcan 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_transactionsmust 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.
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 }.
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.
Three things stack:
- Same process. Medici is a Node library. No language boundary.
- 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.
- Same connection pool. Both Medici and your Mongoose models read
from
mongoose.connection. AClientSessionis 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.
- MongoDB multi-document transactions require a replica set (4.0+).
Standalone
mongodrejectsstartTransaction. - 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 tolocal. - 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 parentAssetsaccount, 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.
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.PoolClientto Rust and have sqlx adopt it. Even within the Rust ecosystem, a sqlxTransaction<'_, 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.
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.