---
title: History and purge
description: 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.
icon: history
---

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](https://developers.cloudflare.com/kv/) namespace, an [R2](https://developers.cloudflare.com/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](/docs/reference/document-api#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.

```jsonc wrangler.jsonc
{
  "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()`:

```ts src/room.ts check
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](/docs/server/room#quotas): 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](/docs/server/room#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.

```ts src/store.ts check
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`](/docs/server/extending#edit-on-the-server) 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:

```ts
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 and `expiresAt`; a version past it is not listed.
- `readHistory(key)` returns the version as a [`JSONDoc`](/docs/reference/document-api), for a preview; `null` when 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) or `refused` (no such version).
- `undoRestore({ user })` undoes the last restore: `applied`, or `noop` when 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`](/docs/server/history-panel) 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`](/docs/server/authorization) authorizes a dial (the same `authorize`, the same refusals, as HTTP statuses):

```ts src/worker.ts check
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.last` is `noop` when 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`](/docs/server/extending#save), the history's slots, the purge's daily tick, the history's [retention](#retention) (for a store without a TTL of its own), the forwarding of a [move](/docs/server/moves)'s late edits, and the sockets' [credential expiry](/docs/server/authorization#revoking-access). 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](/docs/server/internals#alarm-tasks) 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()`](/docs/server/room#metrics-and-log) 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](/docs/server/internals#storage-tables) lists every table the room keeps and what it holds.
