Search by

semitexa / ledger

syntaxwanderer

Semitexa Ledger — SQLite append-only event ledger with NATS JetStream multi-node propagation

Package info

github.com/semitexa/semitexa-ledger

Type:semitexa-module

pkg:composer/semitexa/ledger

Statistics

Installs: 165

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

2026.09.29.0833 2026-09-29 08:51 UTC

README

semitexa/ledger adds an append-only SQLite event ledger with NATS-based cross-node propagation.

The package is opt-in at runtime. Keeping it installed in semitexa/ultimate no longer forces every app to provide ledger infrastructure immediately.

Enable It

Set these environment variables when you want the ledger to boot:

LEDGER_ENABLED=1
LEDGER_NODE_ID=store-a
LEDGER_HMAC_KEY=change-me
NATS_PRIMARY_URL=nats://nats:4222

Optional:

LEDGER_DB_PATH=/var/lib/semitexa/ledger/store-a.sqlite
LEDGER_DB_CONNECTION=default
NATS_SECONDARY_URL=nats://secondary:4222
EVENTS_DUAL_PRIMARY=nats
EVENTS_DUAL_SECONDARY=<other-transport>

Propagate Events

Mark an event with #[Propagated] to persist it in the local ledger and publish it to other nodes.

use Semitexa\Core\Attribute\AsEvent;
use Semitexa\Ledger\Attribute\Propagated;

#[AsEvent]
#[Propagated(domain: 'inventory')]
final class StockAdjusted
{
    private string $productId;
    private int $delta;

    public function getProductId(): string { return $this->productId; }
    public function getDelta(): int { return $this->delta; }
}

Getter/setter DTOs are supported. Ledger payload serialization uses the same getter convention as the core PayloadSerializer. An event that keeps its data in public properties would serialise to an empty payload, so the writer refuses it.

Enforce Aggregate Ownership

Use #[OwnedAggregate] on propagated events and #[AsAggregateCommand] on commands that must execute on the owner node.

use Semitexa\Ledger\Attribute\AsAggregateCommand;
use Semitexa\Ledger\Attribute\OwnedAggregate;
use Semitexa\Ledger\Attribute\Propagated;

#[Propagated(domain: 'inventory')]
#[OwnedAggregate(type: 'product', idField: 'product_id', creates: true)]
final class ProductCreated {}

#[AsAggregateCommand(aggregateType: 'product', aggregateIdField: 'product_id')]
final readonly class UpdateProductPrice
{
    public function __construct(
        public string $product_id,
        public float $new_price,
    ) {}
}

Replay Remote Events

Register an idempotent replay handler for events that must update the local main database.

use Semitexa\Ledger\Attribute\AsReplayHandler;
use Semitexa\Ledger\Domain\Contract\ReplayHandlerInterface;
use Semitexa\Ledger\Domain\Model\LedgerEvent;

#[AsReplayHandler(domain: 'inventory', eventType: 'stock_adjusted')]
final class StockAdjustedReplayHandler implements ReplayHandlerInterface
{
    public function apply(LedgerEvent $event): void
    {
        // Update local projections idempotently using $event->eventId.
    }
}

Check Replication

With the server running on every node:

bin/semitexa ledger:probe            # on node A: append a probe event
bin/semitexa ledger:status           # on node B: events per origin, pending, unapplied, quarantined
bin/semitexa ledger:status --probe=<probe_id> --json
bin/semitexa system:doctor           # ledger.multi-node: node-local defaults that break a cluster

tests/Harness/two-node/run.sh boots two full servers (own database, Redis and ledger each) against one JetStream server and checks both directions plus a burst.

The publisher, replayer and command listener run in worker 0 of each server. Set LEDGER_STREAM and LEDGER_SUBJECT_PREFIX per project when several projects share one NATS server.

CommandBus is not registered in the container yet: owner-routed commands wait for the ownership design.

Current Example In This Repo

packages/semitexa-demo/src/Application/Payload/Event/DemoItemCreated.php and DemoNotificationEvent.php are marked with #[Propagated(domain: 'demo')] as the first live integration inside this workspace.