History
MLS itself does not store messages — once an application message is decrypted, it is gone unless you keep it. Marmot makes message history an optional, pluggable concern: provide a historyFactory to the client and every group gets a history instance at group.history that records both self-sent and ingested rumors and lets you query and subscribe to them.
The library ships GroupRumorHistory, which stores decrypted rumors behind a small backend interface.
Wiring history into the client
GroupRumorHistory.makeFactory turns a per-group backend into the factory the client expects:
import { GroupRumorHistory } from "@internet-privacy/marmot-ts";
import { KeyValueRumorHistoryBackend } from "@internet-privacy/marmot-ts/extra";
const historyFactory = GroupRumorHistory.makeFactory(
(groupId) => new KeyValueRumorHistoryBackend(storeForGroup(groupId)),
);
const client = new MarmotClient({
signer,
network,
groupStateStore,
keyPackageStore,
historyFactory,
});KeyValueRumorHistoryBackend (from ./extra) adapts any GenericKeyValueStore into a history backend, so you can persist messages with the same storage primitives you use for group state.
Once configured, you never call saveMessage yourself — the session writes to history automatically whenever a message is sent or ingested.
Querying messages
group.history is a GroupRumorHistory. Query stored rumors with Nostr filters:
const history = group.history;
// All chat messages (kind 9), newest first
const recent = await history.queryRumors({ kinds: [9], limit: 50 });Live timeline — subscribe
subscribe(filters?) is an async generator that yields the current timeline immediately, then re-yields it whenever a matching rumor is saved or the history is cleared. This is the natural source for a chat view:
for await (const rumors of history.subscribe({ kinds: [9], limit: 100 })) {
renderTimeline(rumors); // newest-first NostrEvent-shaped rumors
}Both self-sent messages (via client.groups.send) and ingested messages (via group.ingest) flow through the same subscription, so the UI stays consistent without a separate "echo" path.
Pagination — createPaginatedLoader
For infinite scroll, createPaginatedLoader(filter?) yields one page per iteration (default 50 per page), walking backwards through history:
const loader = history.createPaginatedLoader({ kinds: [9], limit: 30 });
async function loadOlder() {
const { value: page, done } = await loader.next();
if (!done && page) prependToTimeline(page);
}Other methods and events
saveRumor(rumor)— manually persist an unsigned rumor.purgeMessages()— clear all stored rumors for the group.- Emits
rumor(a rumor was saved) andcleared(history purged).
Retention and backfill
History is independent of MLS epoch secrets. Relay backfill (re-ingesting old kind 445 events) can only decrypt epochs still within the engine's bounded rewind horizon; messages from pruned epochs surface as unreadable during ingest and cannot be recovered — but anything already written to history stays available. Use the message-retention group metadata field (see Proposals) to communicate the intended retention window to members.
Next steps
- Storage — the key/value backends history is built on
- MarmotGroup — sending and ingesting the messages history records
- Messages — the rumor serialization format