Skip to main content

Programmatic API

MiniLedger can be used as an embedded library directly inside your Node.js application. Instead of running a separate miniledger process and communicating via the REST API, you instantiate a ledger node in-process, giving you low-latency access to all ledger operations.

This approach is ideal for:

  • Microservices that need an embedded audit log or state store
  • Testing smart contracts in CI/CD pipelines
  • Desktop or Electron applications with local-first data
  • Serverless functions that need a lightweight persistent ledger

Installation

npm install miniledger

MiniLedger requires Node.js 18 or later.

Quick Start

import { MiniLedger } from 'miniledger';

// Create a node instance
const node = await MiniLedger.create({ dataDir: './my-ledger' });

// Initialize (generates identity, creates genesis block if new)
await node.init();

// Start the node (begins accepting transactions)
await node.start();

// Submit a key-value transaction
await node.submit({ key: 'hello', value: 'world' });

// Query the world state
const rows = await node.query('SELECT * FROM world_state WHERE key = ?', ['hello']);
console.log(rows);
// [{ key: 'hello', value: 'world', version: 1, updated_at: '...', updated_by: '...', block_height: 1 }]

// Stop the node gracefully
await node.stop();

API Reference

MiniLedger.create(options)

Creates a new MiniLedger node instance. This is an async factory method that sets up the internal components but does not start the node.

const node = await MiniLedger.create({
dataDir: './my-ledger',
});

Options:

OptionTypeRequiredDefaultDescription
dataDirstringYes--Path to the directory for storing ledger data (blocks, state, config)

Returns: Promise<MiniLedgerNode>

tip

If the dataDir does not exist, it will be created automatically. If it already contains ledger data, the existing ledger is loaded.

node.init()

Initializes the node. For a new data directory, this generates the node's identity (key pair), creates the genesis block, and sets up the database. For an existing data directory, this loads the existing state.

await node.init();

Returns: Promise<void>

node.start()

Starts the node. After this call, the node accepts transactions and (if configured for multi-node) participates in Raft consensus.

await node.start();

Returns: Promise<void>

Behavior:

  • In single-node mode, the node immediately becomes the leader
  • In multi-node mode, the node begins the Raft election process
  • The P2P and API servers are started if ports are configured
  • Event listeners begin firing

node.stop()

Gracefully stops the node. Flushes pending writes, closes database connections, and shuts down network listeners.

await node.stop();

Returns: Promise<void>

caution

Always call node.stop() before your process exits. Failure to do so may result in data corruption or incomplete writes.

node.submit(transaction)

Submits a transaction to the ledger. The transaction is appended to the Raft log, replicated to peers (in multi-node mode), and committed to the world state.

// Simple key-value write
const receipt = await node.submit({
key: 'user:alice',
value: JSON.stringify({ name: 'Alice', role: 'admin' }),
});

console.log(receipt);
// {
// txId: 'tx-abc123...',
// status: 'committed',
// blockHeight: 5
// }

Parameters:

FieldTypeRequiredDescription
keystringYesThe key to write in the world state
valuestring | number | objectYesThe value to store. Objects are JSON-serialized.

Returns: Promise<TransactionReceipt>

Submitting Typed Transactions

For smart contract deployment and invocation, use the full transaction format:

// Deploy a contract
const deployReceipt = await node.submit({
type: 'contract:deploy',
payload: {
kind: 'contract:deploy',
name: 'counter',
version: '1.0.0',
code: `return {
increment(ctx) {
const count = ctx.get('count') || 0;
ctx.set('count', count + 1);
return count + 1;
},
get(ctx) {
return ctx.get('count') || 0;
}
};`,
},
});

// Invoke a contract
const invokeReceipt = await node.submit({
type: 'contract:invoke',
payload: {
kind: 'contract:invoke',
contract: 'counter',
method: 'increment',
args: [],
},
});
console.log(invokeReceipt.result); // 1

Submitting Governance Transactions

// Submit a governance proposal
await node.submit({
type: 'governance:propose',
payload: {
proposalType: 'update-config',
description: 'Increase block size limit',
params: {
key: 'block.maxSize',
value: 2048,
},
},
});

// Vote on a proposal
await node.submit({
type: 'governance:vote',
payload: {
proposalId: 'prop-abc123...',
vote: 'yes',
},
});

node.query(sql, params?)

Executes a read-only SQL query against the world state.

// Simple query
const rows = await node.query('SELECT * FROM world_state WHERE key LIKE ?', ['balance:%']);

// Aggregation
const [{ total }] = await node.query(
'SELECT SUM(CAST(value AS REAL)) AS total FROM world_state WHERE key LIKE ?',
['balance:%']
);

// No parameters
const allKeys = await node.query('SELECT COUNT(*) AS count FROM world_state');

Parameters:

ParameterTypeRequiredDescription
sqlstringYesSQL query string. Only SELECT statements are allowed.
paramsany[]NoParameterized values for ? placeholders (default: [])

Returns: Promise<Row[]> -- An array of objects, one per result row, with column names as keys.

Events

MiniLedger emits events that you can listen to for real-time monitoring, logging, and application integration. Use the standard Node.js EventEmitter pattern.

Available Events

EventPayloadDescription
block:created{ blockHeight, txCount, timestamp }A new block has been committed to the chain
tx:submitted{ txId, type, key }A transaction has been submitted (before commit)
tx:committed{ txId, type, blockHeight }A transaction has been committed to a block
tx:rejected{ txId, type, error }A transaction was rejected
peer:joined{ peerId, address }A new peer joined the cluster
peer:left{ peerId, reason }A peer left or was removed from the cluster
leader:elected{ leaderId, term }A new Raft leader was elected
state:changed{ key, value, version, blockHeight }A world state entry was created or updated

Listening to Events

import { MiniLedger } from 'miniledger';

const node = await MiniLedger.create({ dataDir: './data' });
await node.init();

// Listen for new blocks
node.on('block:created', (block) => {
console.log(`Block #${block.blockHeight} created with ${block.txCount} transactions`);
});

// Listen for committed transactions
node.on('tx:committed', (tx) => {
console.log(`Transaction ${tx.txId} committed at block ${tx.blockHeight}`);
});

// Listen for rejected transactions
node.on('tx:rejected', (tx) => {
console.error(`Transaction ${tx.txId} rejected: ${tx.error}`);
});

// Listen for state changes on specific keys
node.on('state:changed', (change) => {
if (change.key.startsWith('balance:')) {
console.log(`Balance updated: ${change.key} = ${change.value}`);
}
});

// Listen for cluster membership changes
node.on('peer:joined', (peer) => {
console.log(`Peer ${peer.peerId} joined from ${peer.address}`);
});

node.on('leader:elected', (leader) => {
console.log(`New leader: ${leader.leaderId} (term ${leader.term})`);
});

await node.start();

One-Time Event Listeners

Use node.once() to listen for an event only once:

// Wait for the first block after start
node.once('block:created', (block) => {
console.log('First block created:', block);
});

Removing Event Listeners

const handler = (block) => console.log(block);
node.on('block:created', handler);

// Later...
node.off('block:created', handler);

TypeScript Types

MiniLedger ships with full TypeScript declarations. Import types directly from the package:

import {
MiniLedger,
MiniLedgerNode,
MiniLedgerOptions,
Transaction,
TransactionReceipt,
TransactionType,
QueryResult,
BlockEvent,
TxEvent,
PeerEvent,
LeaderEvent,
StateChangeEvent,
} from 'miniledger';

Key Type Definitions

interface MiniLedgerOptions {
dataDir: string;
}

interface Transaction {
type?: TransactionType;
key?: string;
value?: string | number | object;
payload?: ContractDeployPayload | ContractInvokePayload | GovernancePayload;
}

type TransactionType =
| 'kv:put'
| 'kv:delete'
| 'contract:deploy'
| 'contract:invoke'
| 'governance:propose'
| 'governance:vote'
| 'acl:set';

interface TransactionReceipt {
txId: string;
status: 'committed' | 'rejected';
blockHeight: number;
result?: any;
error?: string;
}

interface ContractDeployPayload {
kind: 'contract:deploy';
name: string;
version: string;
code: string;
}

interface ContractInvokePayload {
kind: 'contract:invoke';
contract: string;
method: string;
args?: any[];
}

interface QueryResult {
key: string;
value: string;
version: number;
updated_at: string;
updated_by: string;
block_height: number;
}

interface BlockEvent {
blockHeight: number;
txCount: number;
timestamp: number;
}

interface TxEvent {
txId: string;
type: TransactionType;
key?: string;
blockHeight?: number;
error?: string;
}

interface PeerEvent {
peerId: string;
address?: string;
reason?: string;
}

interface LeaderEvent {
leaderId: string;
term: number;
}

interface StateChangeEvent {
key: string;
value: any;
version: number;
blockHeight: number;
}

TypeScript Usage Example

import { MiniLedger, TransactionReceipt, QueryResult } from 'miniledger';

async function main(): Promise<void> {
const node = await MiniLedger.create({ dataDir: './ts-ledger' });
await node.init();
await node.start();

// Type-safe transaction submission
const receipt: TransactionReceipt = await node.submit({
key: 'config:version',
value: '2.0.0',
});

if (receipt.status === 'committed') {
console.log(`Committed at block ${receipt.blockHeight}`);
}

// Type-safe queries
const results: QueryResult[] = await node.query(
'SELECT * FROM world_state WHERE key LIKE ?',
['config:%']
);

for (const row of results) {
console.log(`${row.key} = ${row.value} (v${row.version})`);
}

await node.stop();
}

main().catch(console.error);

Complete Application Example

Here is a full example of a simple REST service backed by an embedded MiniLedger node:

import express from 'express';
import { MiniLedger } from 'miniledger';

const app = express();
app.use(express.json());

let node;

// Initialize MiniLedger
async function initLedger() {
node = await MiniLedger.create({ dataDir: './app-ledger' });
await node.init();
await node.start();

node.on('block:created', (block) => {
console.log(`Block #${block.blockHeight} committed`);
});
}

// Write an entry
app.post('/entries', async (req, res) => {
const { key, value } = req.body;

try {
const receipt = await node.submit({ key, value });
res.json(receipt);
} catch (err) {
res.status(400).json({ error: err.message });
}
});

// Read an entry
app.get('/entries/:key', async (req, res) => {
const rows = await node.query(
'SELECT key, value, version, updated_at FROM world_state WHERE key = ?',
[req.params.key]
);

if (rows.length === 0) {
return res.status(404).json({ error: 'Not found' });
}

res.json(rows[0]);
});

// Search entries
app.get('/search', async (req, res) => {
const { prefix, limit = 50 } = req.query;
const rows = await node.query(
'SELECT key, value, version FROM world_state WHERE key LIKE ? LIMIT ?',
[`${prefix}%`, Number(limit)]
);
res.json(rows);
});

// Start the server
initLedger().then(() => {
app.listen(3000, () => {
console.log('App running on http://localhost:3000');
});
});

// Graceful shutdown
process.on('SIGTERM', async () => {
await node.stop();
process.exit(0);
});

process.on('SIGINT', async () => {
await node.stop();
process.exit(0);
});

Multi-Node Programmatic Setup

You can also configure multi-node clusters programmatically:

import { MiniLedger } from 'miniledger';

// Bootstrap node
const node1 = await MiniLedger.create({ dataDir: './node1' });
await node1.init();
await node1.start({
consensus: 'raft',
p2pPort: 4440,
apiPort: 4441,
});

// Joining node
const node2 = await MiniLedger.create({ dataDir: './node2' });
await node2.init();
await node2.start({
consensus: 'raft',
p2pPort: 4442,
apiPort: 4443,
joinAddress: 'ws://localhost:4440',
});

Error Handling

All async methods throw on failure. Use try/catch for error handling:

try {
const receipt = await node.submit({ key: 'test', value: 'data' });

if (receipt.status === 'rejected') {
console.error(`Transaction rejected: ${receipt.error}`);
}
} catch (err) {
if (err.code === 'NOT_LEADER') {
console.error('This node is not the leader. Submit to:', err.leaderAddress);
} else if (err.code === 'NODE_NOT_STARTED') {
console.error('Node has not been started. Call node.start() first.');
} else {
console.error('Unexpected error:', err);
}
}

Common Error Codes

CodeDescription
NOT_LEADERTransaction was submitted to a follower node. Includes leaderAddress for redirect.
NODE_NOT_STARTEDsubmit() or query() called before node.start()
NODE_STOPPEDOperation attempted after node.stop()
INVALID_TRANSACTIONTransaction payload is malformed
CONTRACT_NOT_FOUNDReferenced contract does not exist
EXECUTION_TIMEOUTContract method exceeded the 5-second timeout

Next Steps