History and purge
Keep two versions of each document a day in KV, R2, the room's own storage or a store of your own, list, preview and restore them, and how the room purges deleted content after the same retention.
The room can keep a version history of its document: two versions a day, kept 30 days, in a store you choose — a Workers KV namespace, an R2 bucket, the room’s own Durable Object storage (no binding at all), or any store of your own. Any version can be previewed as JSON and restored as an ordinary edit, which every client receives and which can be undone. Together with the per-block attribution edytor already keeps (lastChangedBy, see attribution), this is the history story: who changed a block last, and what the document looked like this morning or yesterday evening.
The room also purges deleted content. Content deleted more than 30 days ago (the history’s retention by default) is removed from the room’s storage and from every client, so a deleted paragraph does not stay in the document’s storage forever. Within those 30 days, a version or an undo still brings it back.
Choose a store
History is off until you give the room a store. A store is any object with five members (HistoryStore); edytor/cloudflare ships three:
| Store | What it needs | Expiry | Largest version |
|---|---|---|---|
roomHistory() |
Nothing: a history table in the room’s own SQLite storage. |
The room’s alarm deletes each version when it expires. | 32 MiB (maxValueBytes); the table keeps under 256 MiB (maxBytes), oldest versions first. |
kvHistory(namespace) |
A KV namespace binding. | KV’s own TTL. | KV’s 25 MiB. |
r2History(bucket) |
An R2 bucket binding. | The room’s alarm deletes each version when it expires. | 128 MiB. |
With the ready-made DocumentRoom, set EDYTOR_HISTORY and nothing else: a KV namespace binding, an R2 bucket binding, or the variable "room" for the room’s own storage.
{
"durable_objects": {
"bindings": [{ "name": "ROOMS", "class_name": "DocumentRoom" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["DocumentRoom"] }],
// Any one of these three:
"vars": { "EDYTOR_HISTORY": "room", "EDYTOR_HISTORY_TIME_ZONE": "Europe/Paris" }
// "kv_namespaces": [{ "binding": "EDYTOR_HISTORY", "id": "<the namespace id>" }]
// "r2_buckets": [{ "binding": "EDYTOR_HISTORY", "bucket_name": "<the bucket>" }]
}
Create a namespace with npx wrangler kv namespace create edytor-history, a bucket with npx wrangler r2 bucket create edytor-history. EDYTOR_HISTORY_RETENTION_DAYS (30) and EDYTOR_HISTORY_TIME_ZONE (UTC) tune the history. To use another binding, or to decide per room, override history():
import { DocumentRoom, r2History, type HistoryOptions } from 'edytor/cloudflare';
export class Room extends DocumentRoom<Env> {
protected override history(): HistoryOptions | undefined {
return { store: r2History(this.env.VERSIONS), retentionDays: 90, timeZone: 'Europe/Paris' };
}
}
With attachRoom, pass the same object as the history option: attachRoom(this, { history: { store: roomHistory() } }).
The room’s own storage
roomHistory({ maxBytes, maxValueBytes }) keeps the versions in the Durable Object beside the document, in a history table (edytor_history with attachRoom). It needs no binding and no other service, and a version is written atomically: one transaction, split into rows of 1 MiB under the platform’s 2 MB row limit. Its space is counted apart from the document quota: the quota measures what clients send and refuses their edits past it, while the history is the room’s own, so it has its own cap, maxBytes (256 MiB by default; a Durable Object holds up to 10 GB). A version that would pass maxBytes deletes the oldest versions first, so the newest ones are always kept; a version larger than maxValueBytes (32 MiB) is skipped. A generation cutover (reset()) keeps the table.
A store of your own
Any object with these members is a store: D1, a bucket in another cloud, an HTTP service, an in-memory map in tests.
import type { HistoryStore } from 'edytor/cloudflare';
/** Versions in a D1 table: `CREATE TABLE versions (key TEXT PRIMARY KEY, value BLOB, expires_at INTEGER, metadata TEXT)`. */
export const d1History = (db: D1Database): HistoryStore => ({
maxValueBytes: 1_000_000, // D1's largest row is 2 MB
async put(key, value, { expiresAt, metadata }) {
await db
.prepare('INSERT OR REPLACE INTO versions VALUES (?, ?, ?, ?)')
.bind(key, value, expiresAt, JSON.stringify(metadata))
.run();
},
async get(key) {
const row = await db
.prepare('SELECT value, expires_at FROM versions WHERE key = ?')
.bind(key)
.first<{ value: ArrayBuffer; expires_at: number }>();
return row && { value: new Uint8Array(row.value), expiresAt: row.expires_at };
},
async list(prefix, cursor = '') {
const { results } = await db
.prepare('SELECT key, expires_at, metadata FROM versions WHERE key > ? AND key LIKE ? ORDER BY key LIMIT 100')
.bind(cursor, `${prefix}%`)
.all<{ key: string; expires_at: number; metadata: string }>();
const entries = results.map((row) => ({
key: row.key,
expiresAt: row.expires_at,
metadata: JSON.parse(row.metadata)
}));
return { entries, cursor: entries.length === 100 ? entries[99].key : undefined };
},
async delete(key) {
await db.prepare('DELETE FROM versions WHERE key = ?').bind(key).run();
}
});
| Member | |
|---|---|
maxValueBytes |
The largest value the store takes. A larger version is skipped and logged, never split. The history option’s maxValueBytes can only lower it. |
nativeTtl |
Optional. true when the store deletes a value at its expiresAt itself (KV’s TTL): the room then never deletes. |
put(key, value, { expiresAt, metadata }) |
Store the bytes at key, replacing a value there. expiresAt is in ms since the epoch; metadata is a small JSON object ({ bytes, blocks, editors, more?, at }, under 1,024 bytes) to return from list. |
get(key) |
{ value, expiresAt } (expiresAt null when the store does not know), or null. |
list(prefix, cursor?) |
One page of the keys starting with prefix: { entries: [{ key, expiresAt, metadata }], cursor }, cursor absent on the last page. |
delete(key) |
Delete it; a missing key is no error. |
The room owns the rest: the keys (one prefix per room, history/<room id>/), the values, the metadata and the retention.
Retention
Every version gets an expiresAt: when it was written plus retentionDays. The room never lists, previews or restores a version past it, whatever the store still holds. A store with a TTL of its own (nativeTtl, KV) deletes it then. For every other store, the room deletes it: after writing a version, it arms a retention task on its alarm for that version’s expiry; when it runs, it deletes every version of the room past its expiry and re-arms for the next one. A sweep that fails (the store is down) is logged as a history refusal ({ retention: true, error }) and runs again an hour later. A version written while the room’s clock and the store’s disagree still expires by the room’s clock.
KV, before kvHistory
Earlier releases took the KV namespace itself as the store (history: { store: env.HISTORY }). Write kvHistory(env.HISTORY): the namespace itself is refused. The room then keeps no history: it notes a history refusal (history.store: not a HistoryStore), listing answers 404, and a restore throws. kvHistory writes exactly what earlier releases wrote (the same keys, values, metadata and expirationTtl), so versions they stored list, preview and restore as before.
Two versions a day
The day has two slots in the configured time zone (an IANA name such as Europe/Paris, read with Intl): the morning, before 12:00, and the evening, until midnight. A slot that saw a change gets one version, written at its end:
- at local noon for the morning and local midnight for the evening, by the room’s alarm;
- or, if a client writes first after the boundary, just before that write is applied: the version holds the slot’s state exactly;
- or, if the room slept through the boundary, when it wakes.
A slot nobody changed writes nothing, so a document left alone for a week keeps its last versions and writes no new ones. The editors of a slot are the verified users whose edits the room stored in it (a server transact has none; a restore counts its restorer). A day with a daylight-saving change has a 13 or 11 hour morning; the slots follow the local clock.
Each version is one value in the store:
| Key | history/<room id>/<YYYY-MM-DD>-am or -pm, the room id percent-encoded, the date local. |
| Value | The document’s state, as the room’s compaction stores it (gzip-compressed). |
| Metadata | { bytes, blocks, editors, at }: its size, its visible blocks, the slot’s editors, when it was written. Kept under 1,024 bytes (KV’s metadata limit, for every store): past it, the last editors are left out and counted in more. |
| Expiry | expiresAt, the retention (30 days) after it was written: KV’s expirationTtl, R2’s custom metadata, a column of the room’s table. |
A version larger than the store’s maxValueBytes (KV’s 25 MiB, R2’s 128 MiB, the room’s 32 MiB) is not written: the room skips that slot, logs a history refusal ({ key, bytes, limit }) and writes the next one if it fits. Versions are never split across several values, because a store writes them one by one and a failure between two would list a version nobody can read; a compressed state that large holds incompressible data (inline images, say), which belongs in an upload store. (roomHistory splits a version into rows inside one transaction, which is atomic.) A write that fails is logged the same way, and that slot is skipped.
List, preview, restore
The room’s methods are also callable over Durable Object RPC:
const room = env.ROOMS.getByName(documentId);
const versions = await room.listHistory(); // newest first
// [{ key, date: '2026-10-06', slot: 'am', bytes, blocks, editors, more, at }, …]
const preview = await room.readHistory(versions[0].key); // the version as JSON, or null
const result = await room.restoreHistory(versions[0].key, { user: 'ada' });
await room.undoRestore(); // the last restore, once
listHistory()reads the keys of this room only (its prefix), with their metadata andexpiresAt; a version past it is not listed.readHistory(key)returns the version as aJSONDoc, for a preview;nullwhen this room holds no such version (expired, or another room’s key).restoreHistory(key, { user })restores it (below) and returns{ status, key, revived, moved, rewritten, created, deleted }:applied,noop(the document already equals it) orrefused(no such version).undoRestore({ user })undoes the last restore:applied, ornoopwhen there is none to undo.historyWritten()resolves once the versions being written are stored (a slot a write closed is written in the background).
A room without history throws from listHistory, readHistory and restoreHistory.
In the browser, VersionHistory is the controller of a UI over these: the versions with their editors, a read-only preview of one with the blocks added, removed and changed since highlighted, and Restore and Undo restore, through createHistoryClient (the requests below) or a client of your own.
Over HTTP
routeDocumentHistory is the HTTP door to the same methods, authorized as routeDocumentSocket authorizes a dial (the same authorize, the same refusals, as HTTP statuses):
import {
DocumentRoom,
closedSocket,
routeDocumentHistory,
routeDocumentSocket
} from 'edytor/cloudflare';
export { DocumentRoom };
type Env = { ROOMS: DurableObjectNamespace<DocumentRoom> };
const authorize = async (request: Request) => {
const userId = new URL(request.url).searchParams.get('user'); // check a session instead
return userId ? { userId } : null;
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { pathname } = new URL(request.url);
const match = /^\/(?:rooms|history)\/([^/]+)$/.exec(pathname);
if (!match) return new Response('not found', { status: 404 });
let documentId: string; // percent-encoded, as the provider sends it
try {
documentId = decodeURIComponent(match[1]);
} catch {
return closedSocket(4400, 'invalid document id');
}
if (pathname.startsWith('/history/')) {
return routeDocumentHistory(request, env.ROOMS, documentId, authorize);
}
return routeDocumentSocket(request, env.ROOMS, documentId, authorize);
}
} satisfies ExportedHandler<Env>;
Encode the document id in the URL as the provider does (encodeURIComponent), and the key too: /history/${encodeURIComponent(id)}?key=${encodeURIComponent(key)}.
| Request | Answer |
|---|---|
GET <url> |
The versions, newest first, as JSON. |
GET <url>?key=<key> |
That version as JSON; 404 when the room holds none. |
POST <url>?restore=<key> |
Restores it as the verified user; 404 when the room holds none. |
POST <url>?undo |
Undoes the last restore. |
A refusal is a status: 400 for an invalid document id, 401 when authorize returns { expired: true }, 403 when it denies the request or a read-only identity restores, 404 when the room keeps no history, 405 for another method, 503 when the room cannot serve it.
What a restore does
A restore is a forward edit, not a reset: the room writes, in one transaction of its own, what makes the visible document equal the version. It is stored and relayed like any edit, so every client sees it at once, and edits made concurrently merge with it as with any edit (text typed into a block the restore rewrites stays). Block ids are kept wherever the document still holds them:
- A block of the version that still exists keeps its id. If it was deleted since, it shows again. If it was merged into another block since, it is separated again. Its type and data are written only where they differ.
- Its text, when it differs, is rewritten from the first difference to the last: the unchanged beginning and end of the text keep their identity, so carets and attribution in them stay.
- Its place: under its parent in the version, in the version’s order. The blocks already in that order stay where they are; the others move.
- A block the version holds that the document no longer has (purged, possible only when the purge horizon is shorter than the retention) is created.
- A block the version does not hold is deleted.
- The document’s own data is set back to the version’s.
undoRestore() undoes exactly the restore’s transaction, as an undo would: the blocks it deleted show again, the blocks it brought back are deleted, the text it rewrote comes back, and edits made since the restore are kept. Its step is stored with the room, so the undo works after the room slept, until the next restore replaces it or the restore is older than the purge horizon. One level: a second undoRestore() is a noop. A restore that revived a block someone had deleted, then undone, deletes it again as the room’s own delete: that person’s own undo of their delete no longer brings the block back (a version does).
The purge
Deleting in a CRDT keeps what was deleted, for undo and for edits made concurrently elsewhere. The room removes it once it is old enough that neither applies: content deleted more than purgeAfterDays ago is purged from the room and from every client. The default is the history’s retention (30 days), or 30 days without history.
| Setting | Default | |
|---|---|---|
purgeAfterDays (option) or EDYTOR_PURGE_AFTER_DAYS (var) |
the retention, else 30 | Days after which deleted content is purged. false (off) never purges. |
What is purged, once deleted that long ago:
- Deleted blocks. A deleted block nothing depends on is removed whole: its text, data, placement and attribution. A deleted block that a visible block still depends on (its text holds the text of blocks split from it, or a block another person moved into it while it was being deleted shows in its place) keeps its bare structure; its text and data are purged.
- Deleted text. The record of who deleted which characters (what lets an undo bring them back) is removed, and the deleted characters are collected.
- Undone creations. A block whose creation was undone, and that holds nothing, is removed.
- Old placements. A moved block keeps only the placement it shows at.
- Merged blocks’ attribution. A block merged into the one before it (Backspace at its start) keeps its structure, as its text now shows in that block, but its own attribution record (who created it, its contributors) is removed once the merge is that old: the block it merged into took its contributors at the merge, and no undo can split it out again. In documents 50 people edited, the purge then gives back 12 to 17 % of the stored size instead of 5 %.
The purge is the room’s own edit: one transaction, stored and sent to every client, which apply it and collect the content too; a client that loads the document later never receives it. A purged block leaves its id behind as a tombstone (a few bytes): deleting 1,000 of 1,001 blocks and purging them leaves 5.3 KB of stored state for 366 bytes of visible content, from 28.9 KB before the purge. A line split from a paragraph that is still there (Enter makes most lines so) shares that paragraph’s text, so a deleted one keeps its bare structure (about 25 bytes stored) while its text and properties go. On a long editing trace (automerge-paper, 259,778 edits), purging its 77,463 text deletes takes the stored state from 224 KB to 85 KB, and a woken room loads it in a tenth of the time.
When
The room learns when content was deleted from when it stored the deletion. Once a day while the document changes, its alarm records an epoch, the room’s state at that time; content whose deletion the room stored before the newest epoch at least purgeAfterDays old is purged. A deletion is purged within two days of passing purgeAfterDays. The ticks stop once every epoch has passed the horizon, and start again at the next change.
Undo and clients offline for long
- The purge writes its horizon into the document. Every client’s undo history then drops the steps the room stored before it: an undo of such a step restores nothing (
dispatcher.lastisnoopwhen nothing newer is left to undo), and an undo of a newer step works as before. - A client offline for longer than the horizon reconnects as any client does, with no special close code: its edits to content that still exists are kept, and its edits to content purged meanwhile (text typed into a block deleted 40 days ago, say) are dropped on every replica. A block it moved under a purged block shows at the top level. A split of a block deleted past the horizon keeps no text, and an undo it made offline of a step past the horizon is an edit like any other.
Only the room writes the horizon: a client update that writes it is stripped (logged mark).
The room’s alarm
A Durable Object has one alarm. The room runs its tasks on it: onSave, the history’s slots, the purge’s daily tick, the history’s retention (for a store without a TTL of its own), the forwarding of a move’s late edits, and the sockets’ credential expiry. Each task’s due time is stored with the room, the alarm is set to the earliest one, and when it fires every task due at its time runs, then the alarm is set to the next. A task already due keeps its time, so frequent edits or wakes never push a save back. Room internals lists the tasks, when each is due and what happens when one fails. In your own Durable Object, define alarm() only to add work of your own, and call await this.document.alarm() from it.
Metrics and log
metrics() has two more fields: history ({ written, skipped, lastKey }) and purge ({ runs, horizon, removed, emptied, marks, records, candidates, claims, merged }: purges run, the last horizon purged, and what they deleted; merged counts the attribution records of merged blocks). The room logs one JSON line per version written ({ "edytor": "history", "key", "bytes", "editors" }), restore and undo ({ "edytor": "restore", "key", "user", "undo" }) and purge ({ "edytor": "purge", "horizon", "bytes", "removed", … }).
Storage
The history and the purge keep their state in the room’s own tables: epochs (the purge’s daily state vectors), editors (the open slot’s editors), restore (the last restore’s step) and, with roomHistory(), history (the versions), each with the edytor_ prefix under attachRoom. Room internals lists every table the room keeps and what it holds.