# Storage

## The model: append-only log + snapshots

Persistence is an **append-only per-document patch log plus periodic
snapshots**. A document at any sequence number is reconstructed as:

```text
document@seq = (nearest snapshot with snapshotSeq ≤ seq) + (entries snapshotSeq < s ≤ seq)
```

This single model powers three things:

- **crash recovery**: when a room is (re)opened, the server loads the
  latest snapshot plus the entry tail and `replay()`s it into a fresh
  `DocumentSession`;
- **the history/replay API**: see [History](history.md);
- **audit**: every entry records who made it (`clientId` connection id and
  the stable `userId` from the resolved identity).

Writes happen in the submit path: every sequenced entry is **persisted
before it is broadcast** (submits are serialized per document, so persist
order equals seq order), and a snapshot is stored every `snapshotEvery`
entries (default 200, i.e. whenever `seq % snapshotEvery === 0`).

## The `CollabStorage` contract

The extension point for custom backends
(`packages/server/src/storage/types.ts`):

```ts
interface StoredEntry {
	seq: number;
	clientId: string;
	userId?: string; // stable user id from the resolved Identity (attribution)
	patches: readonly Patch[];
}

interface DocRecord {
	snapshotSeq: number;
	snapshot: SerializedElementNode;
	entries: readonly StoredEntry[]; // entries with seq > snapshotSeq, ordered
}

interface HistorySlice {
	snapshotSeq: number;
	snapshot: SerializedElementNode;
	entries: readonly StoredEntry[];
	headSeq: number; // latest seq of the document (for pagination)
}

interface CollabStorage {
	init(): Promise<void>;
	close(): Promise<void>;

	/** Latest snapshot + tail entries, or null when the doc does not exist. */
	load(docId: string): Promise<DocRecord | null>;

	/** Register a new document with its seed snapshot at seq 0. Idempotent. */
	createDoc(docId: string, initialDoc: SerializedElementNode): Promise<void>;

	append(docId: string, entry: StoredEntry): Promise<void>;

	saveSnapshot(
		docId: string,
		seq: number,
		doc: SerializedElementNode
	): Promise<void>;

	/**
	 * Replay slice for fromSeq..toSeq: the greatest snapshot with
	 * seq ≤ fromSeq plus the entries in (snapshotSeq, toSeq].
	 */
	history(
		docId: string,
		fromSeq: number,
		toSeq: number
	): Promise<HistorySlice | null>;

	/**
	 * History retention: keep only the newest keepSnapshots snapshots and the
	 * entries after the oldest kept one; everything older is deleted.
	 */
	prune(docId: string, keepSnapshots: number): Promise<void>;

	/** Comment THREADS (anchors live inside the document itself), by createdAt. */
	listComments(docId: string): Promise<CommentThread[]>;
	saveComment(docId: string, thread: CommentThread): Promise<void>;
	deleteComment(docId: string, commentId: string): Promise<void>;

	/** Erase a document and ALL of its data (right-to-erasure). No-op if absent. */
	deleteDoc(docId: string): Promise<void>;
}
```

Semantics an implementation must honor:

- `load`/`history` return `null` for an unknown document;
- `createDoc` is **idempotent**: a second call for the same `docId` must
  not overwrite the existing document;
- `load` returns the **latest** snapshot and only the entries after it;
- `history(docId, from, to)` picks the greatest snapshot with
  `seq ≤ from`, then the entries in `(snapshotSeq, to]`, plus the current
  `headSeq`;
- `prune(docId, n)` with `n < 1`, or with fewer than `n` stored snapshots,
  is a no-op;
- `saveComment` is an upsert (it is called for add, edit, reply and
  resolve); `listComments` returns threads ordered by `createdAt`.

### Retention (`prune`)

By default the log is kept **forever** (full audit and history replay).
Setting `RETENTION_SNAPSHOTS=N` (config `retentionSnapshots`) makes the
room call `prune(docId, N)` after every stored snapshot: only the newest N
snapshots and the entries after the oldest kept one survive. This bounds
database growth at the price of losing older history: the
[history API](history.md) can then only replay back to the oldest kept
snapshot. Comments are never pruned.

## Built-in implementations

### `memory` (default)

Zero configuration: the full log and all snapshots live in process memory.
Suitable for development and demos, but there is **no persistence across
restarts**, so do not point real users at it.

```js
import { start, MemoryStorage } from '@jodit/collab-server';
await start({ storage: new MemoryStorage() }); // same as the default
```

### `postgres`

Append-only JSONB log plus snapshots in PostgreSQL. `init()` creates the
schema idempotently (`CREATE TABLE IF NOT EXISTS`); no external migration
runner is needed:

```sql
CREATE TABLE IF NOT EXISTS collab_documents (
  doc_id      text PRIMARY KEY,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS collab_entries (
  doc_id      text NOT NULL REFERENCES collab_documents(doc_id) ON DELETE CASCADE,
  seq         integer NOT NULL,
  client_id   text NOT NULL,
  user_id     text,
  patches     jsonb NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (doc_id, seq)
);
CREATE TABLE IF NOT EXISTS collab_snapshots (
  doc_id      text NOT NULL REFERENCES collab_documents(doc_id) ON DELETE CASCADE,
  seq         integer NOT NULL,
  doc         jsonb NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (doc_id, seq)
);
CREATE TABLE IF NOT EXISTS collab_comments (
  doc_id      text NOT NULL REFERENCES collab_documents(doc_id) ON DELETE CASCADE,
  comment_id  text NOT NULL,
  data        jsonb NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (doc_id, comment_id)
);
-- Matches listComments' ORDER BY (data->>'createdAt')::bigint per doc.
CREATE INDEX IF NOT EXISTS collab_comments_by_created
  ON collab_comments (doc_id, ((data->>'createdAt')::bigint));
```

> **Note**
>
> The `(doc_id, seq)` primary key on `collab_entries` guarantees the log
> stays gapless-unique even if two server nodes ever raced: the second
> insert fails loudly instead of silently forking the history.

```js
import { start, PostgresStorage } from '@jodit/collab-server';
await start({
	storage: new PostgresStorage(
		'postgres://collab:collab@localhost:5432/collab'
	)
});
```

## Configuration via environment

When you do not pass a `storage` instance, `start()` builds one from the
environment (`createStorage(env)`):

| Variable         | Values                     | Default  | Notes                                                       |
| ---------------- | -------------------------- | -------- | ----------------------------------------------------------- |
| `STORAGE`        | `memory` \| `postgres`     | `memory` | Any other value throws at startup.                          |
| `DATABASE_URL`   | Postgres connection string | (none)   | Required when `STORAGE=postgres` (startup error otherwise). |
| `SNAPSHOT_EVERY` | integer                    | `200`    | Store a snapshot every N sequenced entries.                 |

## Writing a custom adapter

Implement the `CollabStorage` interface and pass an **instance** to
`start()`; the `storage` config field wins over the `STORAGE` env variable:

```ts
import { start, type CollabStorage } from '@jodit/collab-server';

class RedisStorage implements CollabStorage {
	async init() {
		/* connect, ensure structures */
	}
	async close() {
		/* disconnect */
	}
	async load(docId) {
		/* latest snapshot + tail, or null */
	}
	async createDoc(docId, initialDoc) {
		/* idempotent seed at seq 0 */
	}
	async append(docId, entry) {
		/* append-only */
	}
	async saveSnapshot(docId, seq, doc) {
		/* upsert */
	}
	async history(docId, fromSeq, toSeq) {
		/* snapshot ≤ from + entries ≤ to */
	}
	async prune(docId, keepSnapshots) {
		/* retention; no-op is a valid start */
	}
	async listComments(docId) {
		/* threads by createdAt */
	}
	async saveComment(docId, thread) {
		/* upsert whole thread */
	}
	async deleteComment(docId, commentId) {
		/* delete thread */
	}
}

await start({ storage: new RedisStorage() });
```

`init()` is awaited once inside `start()`; `close()` is awaited by
`server.stop()`.

### The shared contract test suite

Both built-in adapters are tested against **one shared contract suite**
(`packages/server/tests/storage.shared.ts`, `runStorageContract(storage)`),
which exercises: unknown-doc `null`s, create/load round-trip, `createDoc`
idempotency, ordered appends with `userId` attribution, snapshot + tail
semantics of `load`, and the nearest-snapshot/`headSeq` semantics of
`history`. The Postgres adapter runs it against a real database via
Testcontainers.

Run your own adapter through the same function to verify it honors the
contract:

```ts
import { runStorageContract } from 'jodit-collaboration/tests/storage.shared'; // from a repo checkout

it('passes the CollabStorage contract', async () => {
	const storage = new RedisStorage();
	await storage.init();
	await runStorageContract(storage);
	await storage.close();
});
```

> **Tip**
>
> The suite lives in the repository's test tree (it is not part of the
> published `dist`), so copy `storage.shared.ts` into your project or run
> your adapter inside a checkout of this repo.
