Skip to content

In-memory journal

InMemoryJournal stores every event in process memory. It’s the default when you create a system without configuring persistence — fast, zero setup, perfect for tests and dev.

import { InMemoryJournal, InMemorySnapshotStore, PersistenceExtensionId, ActorSystem } from 'actor-ts';
const system = ActorSystem.create('demo');
system.extension(PersistenceExtensionId).configure({
journal: new InMemoryJournal(),
snapshotStore: new InMemorySnapshotStore(),
});
// Now `PersistentActor`s in this system write to the in-memory journal.

It’s also the reference semantics every other journal implementation must match — append-only, monotonic sequence numbers, exact-once delivery to subscribers, concurrency check on expected sequence.

The journal contract:

interface Journal {
append<E>(pid: string, events: E[], expectedSeq: number, tags?: string[]): Promise<PersistentEvent<E>[]>;
read<E>(pid: string, fromSeq: number, toSeq?: number): Promise<PersistentEvent<E>[]>;
highestSeq(pid: string): Promise<number>;
deleteEventsUpTo(pid: string, seqNr: number): Promise<void>;
persistenceIds(): AsyncIterable<string>;
readonly events: JournalEventBus;
}

InMemoryJournal implements all of this with a Map<string, PersistentEvent[]> internally. Operations are:

  • append — push events onto the stream’s array. Concurrency check on expectedSeq (throws JournalConcurrencyError if the current head doesn’t match).
  • read — slice the array by sequence number range.
  • highestSeq — read the last entry’s seqNr (or 0).
  • deleteEventsUpTo — splice off the prefix.
  • persistenceIds — iterate the Map’s keys.

Every append also publishes the event to the in-process JournalEventBus — so push-based queries (see Push-based query) work in real time without polling.

Three legitimate cases:

  1. Tests — fast, no IO, no cleanup needed between tests. Spin up a fresh system, do your assertions, tear down.
  2. Development — when you don’t want to bother with a SQLite file during local iteration.
  3. Throwaway demos — when the data isn’t supposed to survive.

For anything that needs to persist across restarts, switch to SQLite or Cassandra.

import { describe, it, beforeEach, afterEach } from 'bun:test';
import { TestKit, InMemoryJournal, InMemorySnapshotStore, PersistenceExtensionId } from 'actor-ts';
describe('OrderActor', () => {
let tk: TestKit;
beforeEach(() => {
tk = TestKit.create();
tk.system.extension(PersistenceExtensionId).configure({
journal: new InMemoryJournal(),
snapshotStore: new InMemorySnapshotStore(),
});
});
afterEach(async () => { await tk.shutdown(); });
it('replays events on restart', async () => {
const probe = tk.createTestProbe();
// Create + send some commands
let order = tk.system.actorOf(Props.create(() => new OrderActor('order-1')));
order.tell({ kind: 'place', sku: 'book-1' });
order.tell({ kind: 'place', sku: 'book-2' });
await probe.expectMsg({ kind: 'placed', sku: 'book-2' });
// Stop + re-spawn — same persistence ID
await tk.system.stop(order);
order = tk.system.actorOf(Props.create(() => new OrderActor('order-1')));
order.tell({ kind: 'view', replyTo: probe });
// Recovered state should reflect both placed events
await probe.expectMsg({ items: ['book-1', 'book-2'] });
});
});

A fresh InMemoryJournal per test (via per-test TestKit) makes each case isolated — no state leakage between tests.

// Before (test / dev):
system.extension(PersistenceExtensionId).configure({
journal: new InMemoryJournal(),
});
// After (production):
import { SqliteJournal } from 'actor-ts';
system.extension(PersistenceExtensionId).configure({
journal: new SqliteJournal({ path: '/var/lib/my-app/events.db' }),
});

No code changes inside actors — the Journal interface is the same. Just swap the implementation.

This is why we say “use in-memory for tests and dev” — the production switch is one line, and the test suite continues to run against fast in-memory journals.