---
title: "Persistence"
description: "Autosave composition edits through pluggable adapters — filesystem, memory, or your own storage backend."
---
When you pass a `persist` adapter to `openComposition`, the SDK subscribes to every `change` event and schedules an async write automatically. You never call a save method after each edit — the adapter handles it.
```typescript
import { openComposition } from "@hyperframes/sdk";
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
const adapter = createFsAdapter({ root: "./project" });
const comp = await openComposition(html, {
persist: adapter,
persistPath: "composition.html", // default; can be omitted
});
comp.setText("hf-title", "Draft title");
// Autosaved on the next tick — no explicit save call needed.
```
## Options
The storage adapter. The SDK calls `adapter.write(persistPath, html)` after every change. Omit to skip persistence (headless / agent use).
The path key passed to the adapter on every write. Default: `"composition.html"`. Immutable for the session lifetime — changing the path mid-session is not supported.
## Flushing pending writes
The persist queue coalesces rapid edits, so the last write may be deferred when your application closes. Call `flush()` to drain any pending write before exit:
```typescript
// Ensure the latest state is committed before the process exits.
await comp.flush();
comp.dispose();
```
`flush()` resolves when the in-flight write completes. If there is nothing pending it resolves immediately. It is a no-op when no `persist` adapter was provided.
## Handling write failures
Write errors are emitted as events rather than thrown exceptions — a failed autosave must not crash an interactive session. Subscribe with `comp.on('persist:error', …)` to handle them:
```typescript
comp.on("persist:error", ({ error }) => {
console.error("Autosave failed:", error.message, error.cause);
showToast("Could not save — retrying next change.");
});
```
The event carries `{ error: { message: string; hint?: string; cause?: unknown } }`. The SDK retries on the next `change` event, so transient errors recover automatically.
## Shipped adapters
### `createMemoryAdapter()` — tests and demos
An in-process store — no file I/O, no Node.js required. Use it in unit tests, demos, or browser sessions where you want version history without disk access.
```typescript
import { createMemoryAdapter } from "@hyperframes/sdk";
const adapter = createMemoryAdapter();
const comp = await openComposition(html, {
persist: adapter,
});
comp.setText("hf-title", "v1");
await comp.flush();
comp.setText("hf-title", "v2");
await comp.flush();
const versions = await adapter.listVersions("composition.html");
// versions[0] is newest — key "v2", versions[1] is "v1"
const v1Html = await adapter.loadFrom("composition.html", versions[1].key);
```
`createMemoryAdapter()` also exposes `injectFault(message)` — a test helper that makes the next write fire `persist:error` instead of committing. This lets you exercise your error-handling path without real I/O failures.
### `createFsAdapter(opts)` — Node.js local dev
Writes to the filesystem and keeps a rolling version history under `.hf-versions/`. Node.js only — do not use in browser builds.
`createFsAdapter` is imported from the `@hyperframes/sdk/adapters/fs` **subpath**, not the main barrel like `createMemoryAdapter`. The fs adapter pulls in Node's `fs` module, so keeping it on a separate subpath lets browser bundles tree-shake it away — importing it from the root would drag `node:fs` into a browser build.
```typescript
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
const adapter = createFsAdapter({
root: "./project", // required — directory for composition files
maxVersions: 20, // optional — versions to retain per file (default: 20)
});
```
Root directory. The adapter writes `{root}/{persistPath}` and keeps version files under `{root}/.hf-versions/{persistPath}/`.
Maximum version snapshots to retain per file. Oldest are pruned automatically. Default: `20`.
**Listing and restoring versions:**
```typescript
const versions = await adapter.listVersions("composition.html");
// [ { key: "1751234567890-0003", timestamp: 1751234567890 }, ... ]
// Entries are newest-first.
const olderHtml = await adapter.loadFrom("composition.html", versions[2].key);
if (olderHtml) {
// Re-open the old snapshot.
const restored = await openComposition(olderHtml, { persist: adapter });
}
```
Version keys are `{timestamp}-{counter}` strings. `listVersions` returns entries newest-first; `timestamp` is the Unix milliseconds at the time of the write.
## Implementing a custom adapter
Any object that satisfies the `PersistAdapter` interface works as a drop-in. The interface is:
```typescript
export interface PersistAdapter {
read(path: string): Promise;
write(path: string, content: string): Promise;
flush(): Promise;
listVersions(path: string): Promise;
loadFrom(path: string, versionKey: string): Promise;
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
}
```
**Contract:**
- `read()` returns `undefined` for a path that has never been written.
- `write()` is idempotent — a second write to the same path overwrites.
- `flush()` resolves when any queued writes are committed. Return `Promise.resolve()` if writes are synchronous.
- `listVersions()` returns entries newest-first. Entries without stored `content` are valid — `loadFrom` is called lazily by the consumer.
- `loadFrom()` returns `undefined` when the version key is not found.
- `on('persist:error', handler)` registers an error listener. Errors **must not** propagate as thrown exceptions — surface them through the event instead. Returns an unsubscribe function.
**Minimal example — an HTTP / S3-style adapter:**
```typescript
import type { PersistAdapter, PersistVersionEntry, PersistErrorEvent } from "@hyperframes/sdk";
export function createHttpAdapter(baseUrl: string): PersistAdapter {
const errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
return {
async read(path) {
const res = await fetch(`${baseUrl}/${path}`);
if (res.status === 404) return undefined;
return res.text();
},
async write(path, content) {
try {
await fetch(`${baseUrl}/${path}`, {
method: "PUT",
body: content,
headers: { "Content-Type": "text/html" },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errorHandlers.forEach((h) => h({ error: { message, cause: err } }));
}
},
async flush() {
// No queuing — writes are immediate.
},
async listVersions(path): Promise {
const res = await fetch(`${baseUrl}/${path}/versions`);
if (!res.ok) return [];
return res.json() as Promise;
},
async loadFrom(path, versionKey) {
const res = await fetch(`${baseUrl}/${path}/versions/${versionKey}`);
if (!res.ok) return undefined;
return res.text();
},
on(event, handler) {
if (event !== "persist:error") return () => {};
errorHandlers.push(handler);
return () => {
const i = errorHandlers.indexOf(handler);
if (i !== -1) errorHandlers.splice(i, 1);
};
},
};
}
```
`write()` must never throw — catch errors and emit them through `on('persist:error', …)` instead. The SDK trusts that writes are fire-and-forget from its side; thrown exceptions break the internal persist queue.
Full `PersistAdapter`, `PersistVersionEntry`, and `PreviewAdapter` type definitions.
All `OpenCompositionOptions` fields including `persist`, `persistPath`, `preview`, and `overrides`.