Skip to content

Event Log

The event log is Effect’s building block for local-first and offline-capable applications. Instead of mutating shared state directly, your app defines a catalog of typed events — durable facts like TodoCreated or TodoCompleted — and writes them through a runtime that persists each one to a journal and runs a handler that updates your projections. Because the journal is the source of truth, the same events can be replayed locally on startup and synchronized to a remote server so multiple devices converge on the same log.

An event is a durable fact with three stable parts:

  • a tag — the name of the kind of fact ("TodoCreated"),
  • a primary key — derived deterministically from the decoded payload, it groups related entries (all events for one todo share its id), and
  • a payload schema — describes the bytes persisted to the journal.

Writing an event locally is command-like. The runtime:

  1. encodes the payload with the event’s MessagePack schema,
  2. derives the primary key from the decoded payload,
  3. creates a journal entry (a UUID v7 id gives it a clock-ordered identity),
  4. runs your handler for that tag, then
  5. commits the entry to the journal only if the handler succeeds.

Remote replay is journal-like: entries received from a remote are decoded with the same schemas, any conflicting entries for the same primary key are supplied to the handler, and the handler runs again to fold the remote fact into your local projection.

Contrast this with plain Effect state (a Ref, a database row): plain state has no durable history, no deterministic replay, and no built-in story for syncing two offline clients. The event log keeps the history of facts as the authoritative record and treats your projections as a derived, rebuildable view.

Event.make ──▶ EventGroup.empty.add(...) ──▶ EventLog.schema(group)
EventLog.group(group, handlers) ──┐
client = EventLog.makeClient(schema) EventLog runtime
│ write(tag, payload) │ encode ▸ handle ▸ commit
▼ ▼
─────────────────────── EventJournal ───────────────────────
(layerMemory / IndexedDb / SQL) ◀── optional ──▶ EventLogRemote (sync)
ModuleRole
EventDefine one event: tag, primary key, payload / success / error schemas.
EventGroupAn immutable catalog of events for one domain.
EventLogThe runtime: build a schema, register handlers, get a typed client.
EventJournalPersistence: in-memory, IndexedDB, or SQL.
EventLogRemoteClient-side sync to a remote replica (encrypted or plaintext).
EventLogEncryptionEnd-to-end crypto identity derived from the private key.
EventLogServerThe sync server that other replicas talk to.

The first four are covered in depth below. For EventLogRemote, EventLogEncryption, and the rest of the public surface see the full reference; for running a server see the sync server guide.

Events are defined with Event.make. Use schemas from core effect for the payload. The primaryKey function turns the decoded payload into a stable string that groups related entries.

import { Schema } from "effect"
import * as Event from "effect/unstable/eventlog/Event"
const Todo = Schema.Struct({
id: Schema.String,
text: Schema.String
})
// A fact: a todo was created. The payload IS the persisted record.
const TodoCreated = Event.make({
tag: "TodoCreated",
primaryKey: (payload) => payload.id, // group all entries for this todo
payload: Todo
})
// A fact: a todo was completed. Payload only needs the id.
const TodoCompleted = Event.make({
tag: "TodoCompleted",
primaryKey: (payload) => payload.id,
payload: Schema.Struct({ id: Schema.String })
})
// A fact: a todo was deleted.
const TodoDeleted = Event.make({
tag: "TodoDeleted",
primaryKey: (payload) => payload.id,
payload: Schema.Struct({ id: Schema.String })
})

Each event can also declare a success schema (the typed value its handler returns to the writer) and an error schema (the typed failures a handler may raise). When omitted, payload and success default to Schema.Void and error defaults to Schema.Never.

An EventGroup is the catalog of events for one domain. Start from EventGroup.empty and chain .add(...). You can define events inline in .add (it takes the same options as Event.make) or build them separately and re-add their options.

import { Schema } from "effect"
import * as EventGroup from "effect/unstable/eventlog/EventGroup"
const Todo = Schema.Struct({ id: Schema.String, text: Schema.String })
const TodosGroup = EventGroup.empty
.add({
tag: "TodoCreated",
primaryKey: (payload: { id: string }) => payload.id,
payload: Todo
})
.add({
tag: "TodoCompleted",
primaryKey: (payload: { id: string }) => payload.id,
payload: Schema.Struct({ id: Schema.String })
})
.add({
tag: "TodoDeleted",
primaryKey: (payload: { id: string }) => payload.id,
payload: Schema.Struct({ id: Schema.String })
})

Use .addError(schema) to attach a shared error to every event in the group (handy for a StoreFull or Forbidden error common to the whole domain):

import { Schema } from "effect"
class StoreFull extends Schema.TaggedErrorClass<StoreFull>()("StoreFull", {}) {}
const GuardedTodos = TodosGroup.addError(StoreFull)
// every event's handler may now fail with StoreFull

EventLog.schema(...groups) combines one or more groups into an EventLogSchema. EventLog.makeClient(schema) returns an Effect that yields a typed write function. Calling client(tag, payload) is fully inferred: the success type comes from the event’s success schema and the error type is the event’s error union plus EventJournalError.

import { Effect } from "effect"
import * as EventLog from "effect/unstable/eventlog/EventLog"
const TodosSchema = EventLog.schema(TodosGroup)
const program = Effect.gen(function* () {
const client = yield* EventLog.makeClient(TodosSchema)
// Effect<void, EventJournalError, never> (success defaults to Schema.Void)
yield* client("TodoCreated", { id: "1", text: "Buy milk" })
yield* client("TodoCompleted", { id: "1" })
})
// program requires the EventLog service — provided by EventLog.layer below

The client is just a thin wrapper over the EventLog service’s write method, so it requires EventLog in context. That service is installed by a layer.

Handlers are where an event becomes an effect on your projection. Register them with EventLog.group(group, (handlers) => ...). The builder’s .handle is checked at the type level: every event tag in the group must be handled, or the return type becomes a compile-time error string (ValidateReturn).

Each handler receives { storeId, payload, entry, conflicts }:

  • storeId — the logical store this write belongs to (see CurrentStoreId),
  • payload — the decoded payload for this tag,
  • entry — the journal Entry (has id, idString, createdAt, primaryKey),
  • conflicts — for remote replay, other entries for the same primary key.

Because the entry commits only if the handler succeeds, the handler is the right place to update a projection transactionally.

import { Effect, Ref } from "effect"
import * as EventLog from "effect/unstable/eventlog/EventLog"
interface TodoView {
readonly id: string
readonly text: string
readonly completed: boolean
}
// A simple in-memory projection keyed by todo id.
const makeTodosHandlers = (state: Ref.Ref<ReadonlyMap<string, TodoView>>) =>
EventLog.group(TodosGroup, (handlers) =>
handlers
.handle(
"TodoCreated",
Effect.fn("TodoCreated")(function* ({ payload }) {
yield* Ref.update(state, (map) =>
new Map(map).set(payload.id, {
id: payload.id,
text: payload.text,
completed: false
})
)
})
)
.handle(
"TodoCompleted",
Effect.fn("TodoCompleted")(function* ({ payload }) {
yield* Ref.update(state, (map) => {
const next = new Map(map)
const todo = next.get(payload.id)
if (todo) next.set(payload.id, { ...todo, completed: true })
return next
})
})
)
.handle(
"TodoDeleted",
Effect.fn("TodoDeleted")(function* ({ payload }) {
yield* Ref.update(state, (map) => {
const next = new Map(map)
next.delete(payload.id)
return next
})
})
)
)

The runtime needs three things in its layer stack:

  1. an EventJournal (start with EventJournal.layerMemory),
  2. an Identity (a keypair that names this replica’s log), and
  3. the handler layer combined with the runtime via EventLog.layer.

EventLog.layer(schema, handlerLayer) provides EventLog | Registry and still requires EventJournal and Identity. Generate an identity with EventLog.makeIdentity (requires EventLogEncryption), or persist one as a string with encodeIdentityString / decodeIdentityString.

import { Effect, Layer, Ref } from "effect"
import * as EventLog from "effect/unstable/eventlog/EventLog"
import * as EventJournal from "effect/unstable/eventlog/EventJournal"
import * as EventLogEncryption from "effect/unstable/eventlog/EventLogEncryption"
// 1. The handler layer. We allocate the projection Ref inside the layer's
// construction effect, so the handlers close over it. `Layer.unwrap`
// turns an Effect-that-produces-a-Layer into a Layer.
const handlerLayer = Layer.unwrap(
Effect.gen(function* () {
const state = yield* Ref.make<ReadonlyMap<string, TodoView>>(new Map())
return makeTodosHandlers(state)
})
)
// 2. Combine the handler layer with the EventLog runtime for the schema.
// => Layer<EventLog | Registry, never, EventJournal | Identity>
const RuntimeLayer = EventLog.layer(TodosSchema, handlerLayer)
// 3. Provide an Identity (generated here) and a journal underneath.
const IdentityLayer = Layer.effect(EventLog.Identity, EventLog.makeIdentity).pipe(
Layer.provide(EventLogEncryption.layerSubtle)
)
const AppLayer = RuntimeLayer.pipe(
Layer.provide(EventJournal.layerMemory),
Layer.provide(IdentityLayer)
)

CurrentStoreId is a Context.Reference that defaults to the branded store id "default". Override it to run multiple logical stores against one journal.

Under EventLog.layer sit two lower-level pieces you rarely wire by hand: EventLog.layerEventLog installs the EventLog service and Registry given a journal and identity, and EventLog.layerRegistry provides just the in-memory Registry that collects handlers, compactors, remotes, and reactivity keys.

To synchronize a local log with a server, add an EventLogRemote to the layer stack. EventLogRemote.layerEncrypted (the default for untrusted networks) and EventLogRemote.layerUnencrypted (trusted transports / tests) each register a remote with the Registry. Once registered, sync runs automatically for the current Identity + CurrentStoreId: local writes are pushed and remote changes are streamed in and replayed through your handlers.

import * as EventLogRemote from "effect/unstable/eventlog/EventLogRemote"
// Encrypted remote — requires an RpcClient.Protocol layer for the transport.
const RemoteLayer = EventLogRemote.layerEncrypted
// const RemoteLayer = EventLogRemote.layerUnencrypted // plaintext alternative

Both layers require an RpcClient.Protocol (WebSocket, HTTP, worker, …) plus the Registry from the runtime. See the sync server guide for the server side and the reference for makeEncrypted / makeUnencrypted, whenAuthenticated, and the encryption identity model.

EventLog.groupReactivity(group, keys) registers reactivity keys to invalidate when events from the group are written or replayed, so derived views recompute. Pass a single key list (applied to every event) or a per-tag map.

import * as EventLog from "effect/unstable/eventlog/EventLog"
const TodosReactivity = EventLog.groupReactivity(TodosGroup, ["todos"])
// invalidates the "todos" reactivity key on any todo event, by primary key

EventLog.groupCompaction(group, effect) collapses history during remote replay: matching entries are grouped by primary key and handed to your effect, which can write replacement entries. Use it to discard superseded facts (e.g. keep only the latest state for a key) before they hit the journal. See the reference for the full signatures.


The moves you reach for most when wiring an event log. Each links to the full reference for the complete signatures.

import { Schema } from "effect"
import * as EventGroup from "effect/unstable/eventlog/EventGroup"
// Inline in a group: tag + primaryKey + payload (success/error optional)
const Todos = EventGroup.empty.add({
tag: "TodoCreated",
primaryKey: (p: { id: string }) => p.id,
payload: Schema.Struct({ id: Schema.String, text: Schema.String }),
success: Schema.String // handler returns the new id
})
import { Effect } from "effect"
import * as EventLog from "effect/unstable/eventlog/EventLog"
const schema = EventLog.schema(Todos)
Effect.gen(function* () {
const write = yield* EventLog.makeClient(schema)
const id = yield* write("TodoCreated", { id: "1", text: "Buy milk" })
// => Effect<string, EventJournalError, EventLog>
})

Start in memory, swap to IndexedDB in the browser

Section titled “Start in memory, swap to IndexedDB in the browser”
import * as EventJournal from "effect/unstable/eventlog/EventJournal"
EventJournal.layerMemory // throwaway, lost when scope ends
EventJournal.layerIndexedDb({ database: "my_app" }) // local-first web app
import * as EventLog from "effect/unstable/eventlog/EventLog"
// EventLog.makeIdentity : Effect<Identity, never, EventLogEncryption>
const str = EventLog.encodeIdentityString(identity) // => "eyJ..." (base64url)
const back = EventLog.decodeIdentityString(str) // restore from localStorage

Invalidate reactivity keys when events are written

Section titled “Invalidate reactivity keys when events are written”
import * as EventLog from "effect/unstable/eventlog/EventLog"
EventLog.groupReactivity(Todos, ["todos"]) // any todo event -> "todos"
EventLog.groupReactivity(Todos, { TodoCreated: ["todos", "counts"] })
import { Effect } from "effect"
import * as EventLog from "effect/unstable/eventlog/EventLog"
import { StoreId } from "effect/unstable/eventlog/EventLogMessage"
// CurrentStoreId is a Context.Reference defaulting to StoreId.make("default")
Effect.provideService(program, EventLog.CurrentStoreId, StoreId.make("ws-42"))
  • Reference — the exhaustive client-side API across every module: Event, EventGroup, EventLog, EventJournal (in-memory, IndexedDB, and SQL), EventLogRemote, and EventLogEncryption, with all type helpers, identity APIs, and protocol types.
  • Sync server — stand up an EventLogServer so replicas synchronize: session authentication, encrypted and unencrypted storage, server-side compaction/projection, and SQL-backed persistence.