Chelonia can keep an optional per-contract journal of every event applied to a contract's state. For each processed event the journal records:
- The event's identity (
hash,height,opType,description), - A strict-subset RFC-6902 JSON Patch diff between the contract state before and after processing,
- Periodic full snapshots so the journal stays bounded and the state at HEAD remains reconstructible.
The journal is opt-in: enabled defaults to false. Turn it on
via chelonia/configure (see configure.md).
- Why journal
- Enabling and tuning the journal
- Public selectors
- Redactions
- Resync, gaps, and failed events
- Storage layout and observability
- Custom
diff/applyPatch - Full example: enable, read, reconstruct, clear
The journal is useful for:
- Audit logs / debugging: replay exactly what happened to a contract's state, including ops that failed processing.
- Time-travel views: reconstruct historical state without re-syncing from the relay server.
- Compliance: keep a redacted record of state mutations while scrubbing sensitive fields before they hit disk.
It is not a backup of contract messages — it records state diffs
after processing, not raw signed events. Use chelonia.db/* for
message persistence.
import sbp from '@sbp/sbp'
import '@chelonia/lib'
import { shortHashRedactor, defaultDiff, defaultApplyPatch, DEFAULT_SNAPSHOT_INTERVAL } from '@chelonia/lib'
// Equivalent, for consumers that want to tree-shake just the journal helpers:
// import { shortHashRedactor, defaultDiff, defaultApplyPatch, DEFAULT_SNAPSHOT_INTERVAL } from '@chelonia/lib/journal'
await sbp('chelonia/configure', {
journal: {
enabled: true,
snapshotInterval: 50, // default DEFAULT_SNAPSHOT_INTERVAL
contractIDs: [], // empty = all contracts; or whitelist a subset
redactions: [
{ path: 'profiles.*.email', redact: shortHashRedactor },
{ path: 'secrets.apiKey', redact: () => '[REDACTED]' }
],
diff: defaultDiff, // optional override
applyPatch: defaultApplyPatch // optional override
}
})| Field | Default | Notes |
|---|---|---|
enabled |
false |
Master switch. Must be a literal boolean. |
snapshotInterval |
50 |
Snapshot every N patches; trim to most recent snapshot at 2N. Non-positive / non-integer values fall back to 50 with a console.warn. |
contractIDs |
[] (all) |
Whitelist a subset. Stored via .slice(). |
redactions |
[] |
{ path, redact } directives applied before diffing. Deep-copied. |
diff |
defaultDiff |
RFC-6902 subset diff implementation. |
applyPatch |
defaultApplyPatch |
Patch applier used by chelonia/journal/reconstruct. |
See configure.md for the
per-field validation and reconfigure rules.
With snapshotInterval = N the journal contains between N and 2N
entries per active contract:
- The first event records a
snapshot. - Subsequent events record
patchentries. - After
Npatches, a freshsnapshotis recorded. - Once the journal reaches
2Nentries, everything before the most recent snapshot is trimmed.
The first entry is always a snapshot, so
chelonia/journal/reconstruct can rebuild HEAD state by replaying
patches over it.
All three are registered by importing @chelonia/lib.
Returns a deep clone of the contract's journal block, or undefined
if the contract has no journal.
const journal = sbp('chelonia/journal/get', contractID)
journal?.entries.forEach((e) => {
if (e.kind === 'snapshot') console.log('snap @', e.height, e.opType)
else console.log('patch @', e.height, e.patch.length, 'ops')
})JournalEntry is a discriminated union:
type JournalEntry =
| {
kind: 'snapshot'
hash: string; height: number; opType: string
description?: string
state: unknown // redacted clone of post-event state; null if
// the contract state was undefined (e.g., failed
// first-message processing)
error?: { name: string; message: string } // set if processMutation threw
// on a first-event / resync /
// forward-gap snapshot
}
| {
kind: 'patch'
hash: string; height: number; opType: string
description?: string
patch: JournalPatch[]
error?: { name: string; message: string } // set if processMutation threw
}description is optional: chelonia/private/journal/recordEvent
wraps SPMessage.description() in a try/catch and falls back to
undefined if it throws. Don't assume it's always present.
JournalPatch is RFC-6902-compatible (only add / remove /
replace), with RFC-6901 JSON-Pointer paths. Root-removal is
represented as { op: 'replace', path: '', value: null } because
RFC-6902 doesn't define root-remove.
Replays the journal from its most recent snapshot to recover the
contract's HEAD state, with redactions already applied. Returns
undefined if there is no journal for contractID, or if the
stored journal is empty / contains no snapshot to seed from.
Throws ChelErrorJournalCorrupt (with entryIndex, contractID, and
cause) if a recorded patch fails to apply.
import { ChelErrorJournalCorrupt } from '@chelonia/lib'
try {
const state = sbp('chelonia/journal/reconstruct', contractID)
console.log('redacted HEAD state:', state)
} catch (e) {
if (e instanceof ChelErrorJournalCorrupt) {
console.warn('journal corrupt at entry', e.entryIndex, '— clearing')
sbp('chelonia/journal/clear', contractID)
} else throw e
}Clears one contract's journal, or all journals if called with no argument. Returns the number of contracts cleared.
sbp('chelonia/journal/clear', contractID) // clear one
sbp('chelonia/journal/clear') // clear allYou'll typically want to call this:
- When you genuinely change the
redactionssemantics (Chelonia does not auto-clear — seeconfigure.md). - When
chelonia/journal/reconstructreports corruption. - On logout, before the next user signs in (or use
chelonia/resetwhich clears state more broadly).
Redactions are applied to both the before-state and the
after-state before diffing. That means the journal never holds
the raw value of a redacted field — and reconstruct returns the
already-redacted view.
{ path: 'profiles.*.email', redact: shortHashRedactor }pathis a dotted segment list (a.b.c).*matches any single key or array index.redact(value, fullPath, contractName)MUST:- Be pure (no I/O, no side-effects),
- Not mutate
value, - Return the replacement.
shortHashRedactor(value)— returns the first 8 characters of the base58btc blake2b-256 hash ofJSON.stringify(value). Good for high-entropy values (emails, UUIDs, contract IDs). Trivially reversible for low-entropy values (booleans, small ints, short enums) — anyone with the journal and the contract schema can precompute the mapping.
For low-entropy fields, use a constant sentinel:
{ path: 'profiles.*.role', redact: () => '[REDACTED]' }Redactions cover state only. The description field on each
journal entry is a copy of SPMessage.description() and is not
redacted. For unencrypted ops description() can echo action data —
treat the journal at the same trust level as the description output.
If you need to scrub descriptions, strip them after reading via
chelonia/journal/get.
The error.name / error.message fields on failed-event patches are
also not redacted. Strip them downstream if leakage is a concern.
The journal recorder watches each event's height to decide between
three cases:
| Case | Action |
|---|---|
height < lastEntry.height (strict backwards) |
Resync detected. Journal is collapsed to a fresh snapshot. |
height > lastEntry.height + 1 (gap) |
Also treated as a resync — entries are missing (e.g. journaling was toggled off and back on, or contractIDs was widened). Cached before-state no longer matches, so producing a patch would corrupt reconstruct. Re-seeds with a snapshot. |
height === lastEntry.height (duplicate) |
Ignored; journal unchanged. |
height === lastEntry.height + 1 (normal) |
Patch entry recorded. |
If processMutation throws and Chelonia discards the mutation, the
recorder still emits an entry — empty patch: [] plus an additional
error: { name, message } field copied from the captured Error:
{
kind: 'patch',
hash: 'h7', height: 7, opType: 'ae',
patch: [],
error: { name: 'ChelErrorSignatureError', message: '...' }
}The same error: { name, message } field is attached to snapshot
entries when the failure lands on a snapshot path — i.e. the first
event for a contract, or a resync / forward-gap re-seed (see
Resync, gaps, and failed events). Without this, the error detail
would be silently lost on those three paths because they emit only a
snapshot and no accompanying patch entry.
{
kind: 'snapshot',
hash: 'h0', height: 0, opType: 'c',
state: null, // post-state was undefined because the mutation threw
error: { name: 'ChelErrorSignatureError', message: '...' }
}This makes failed events distinguishable from no-op events on every path the recorder emits.
The recorder is wrapped in a top-level try/catch and MUST NOT
throw — a journal bug can never break event handling.
The journal is stored at state.contracts[contractID]._journal,
alongside HEAD / height / previousKeyOp (not on the contract's
own state). It is serialized by any persistence layer that snapshots
that subtree.
With enabled: true, expect per active contract:
- Up to two redacted full-state snapshots,
- Plus up to ~
snapshotIntervalpatch entries.
Tune snapshotInterval down (or set enabled: false) if state size
is a concern.
Because _journal is an own property of state.contracts[contractID],
it travels with anything exposing that subtree:
chelonia/contract/fullStatereturnscheloniaState: rootState.contracts[contractID]verbatim.EVENT_HANDLEDlisteners that snapshotstate.contracts(e.g. the Vuex mirror set up bychelonia/externalStateSetup) receive the journal too.
Treat the journal as in-band with the rest of the bookkeeping subtree. If you need a journal-free view, project it out client-side:
const { _journal, ...cheloniaStateWithoutJournal } = cheloniaStateTypeScript consumers: _journal is typed as optional on the
bookkeeping subtree ({ entries: JournalEntry[] } | undefined), so
the destructured _journal is undefined whenever the contract
never enabled journaling. Ignore the binding (or void _journal) if
your lint config flags unused destructured names.
The journal API itself remains accessible via chelonia/journal/get.
The journal's deep-clone is JSON-shape only. Date / Map /
Set / Buffer / class instances inside contract state are passed
through by reference rather than copied. In-place mutation of such
values would retroactively mutate prior journal entries and confuse
the before === after short-circuit in the diff.
Either:
- Keep contract state plain JSON, or
- Swap in a
structuredClone-baseddiff/applyPatchoverride (see next section).
import type { JournalPatch } from '@chelonia/lib'
const structuredDiff = (before: unknown, after: unknown): JournalPatch[] => {
// Your implementation here. Must return an RFC-6902-compatible patch
// array (Chelonia restricts to add/remove/replace by convention).
}
const structuredApply = (state: unknown, patches: JournalPatch[]): unknown => {
// Your applier here. Must be pure.
}
await sbp('chelonia/configure', {
journal: { diff: structuredDiff, applyPatch: structuredApply }
})To revert to the built-ins after a swap, pass them explicitly:
import { defaultDiff, defaultApplyPatch } from '@chelonia/lib'
await sbp('chelonia/configure', {
journal: { diff: defaultDiff, applyPatch: defaultApplyPatch }
})(Or use journal: null to reset the whole block to disabled
defaults — which also wipes existing journals.)
import sbp from '@sbp/sbp'
import '@chelonia/lib'
import {
shortHashRedactor,
defaultDiff,
defaultApplyPatch,
ChelErrorJournalCorrupt,
DEFAULT_SNAPSHOT_INTERVAL
} from '@chelonia/lib'
// 1. Enable journaling. (See configure.md for the rest of the config.)
await sbp('chelonia/configure', {
// ... other configuration (connectionURL, contracts, hooks, etc.) ...
journal: {
enabled: true,
snapshotInterval: 25,
contractIDs: [],
redactions: [
{ path: 'profiles.*.email', redact: shortHashRedactor },
{ path: 'secrets.*', redact: () => '[REDACTED]' }
],
diff: defaultDiff,
applyPatch: defaultApplyPatch
}
})
// ... bootstrap your contracts, sync them, send actions ...
// (see docs/configure.md for the full bootstrap example)
// 2. Read the journal for a contract.
const journal = sbp('chelonia/journal/get', contractID)
if (journal) {
for (const e of journal.entries) {
if (e.kind === 'snapshot') {
console.log(`[snap] @${e.height} ${e.opType} (${e.hash})`)
} else if (e.error) {
console.log(`[err ] @${e.height} ${e.opType}: ${e.error.name}: ${e.error.message}`)
} else {
console.log(`[diff] @${e.height} ${e.opType} +${e.patch.length} ops`)
}
}
}
// 3. Reconstruct redacted HEAD state without re-syncing.
try {
const head = sbp('chelonia/journal/reconstruct', contractID)
console.log('redacted HEAD:', head)
} catch (e) {
if (e instanceof ChelErrorJournalCorrupt) {
console.warn(`corrupt at entry ${e.entryIndex} — clearing`)
sbp('chelonia/journal/clear', contractID)
} else {
throw e
}
}
// 4. Clear one contract's journal (e.g. before sharing fullState off-device).
sbp('chelonia/journal/clear', contractID)
// 5. Clear all journals.
sbp('chelonia/journal/clear')
// 6. Stop journaling entirely. Resets the journal config block to
// disabled defaults AND wipes every persisted journal.
await sbp('chelonia/configure', { journal: null })For the full configuration surface (and the rest of the
chelonia/configure selector), see configure.md.