Storage adapters
Workspace persistence goes through a small async interface. Ezynota ships with two implementations, and you can bring your own to sync with a server.
Built-in adapters
import { Ezynota, IndexedDbStorage, MemoryStorage } from "@bookklik/ezynota";
// Default — persists in the browser via IndexedDB
const editor = new Ezynota({ target: "#app", mode: "workspace" });
// Explicit
const editor2 = new Ezynota({
target: "#app2",
mode: "workspace",
storage: new IndexedDbStorage(),
});
// In-memory (tests, demos)
const demo = new Ezynota({
target: "#demo",
mode: "workspace",
storage: new MemoryStorage(false),
});emptyWorkspace(id) creates a valid, empty WorkspaceEnvelope. MemoryStorage(crossTab = true) accepts a boolean, not initial workspace data. Seed it with commit(id, envelope, 0) when needed.
Temporary storage
MemoryStorage and an IndexedDB adapter that falls back after an open failure report persistent: false. The editor shows a persistent backup notice and uses the temporary save status instead of saved. Download a workspace backup before closing or reloading. Custom adapters can expose persistent; omitted values retain the existing assumption that successful writes are durable.
StorageAdapter interface
interface StorageAdapter {
readonly persistent?: boolean;
init(): Promise<void>;
loadWorkspace(id: string): Promise<WorkspaceEnvelope | null>;
commit(id: string, envelope: WorkspaceEnvelope, expectedRevision: number): Promise<CommitResult>;
loadAsset(workspaceId: string, assetId: string): Promise<WorkspaceAsset | null>;
saveAsset(workspaceId: string, asset: WorkspaceAsset): Promise<boolean>;
subscribe(id: string, cb: () => void): () => void; // return a no-op disposer if unsupported
close(): Promise<void>;
}CommitResult
Commit may reject stale writes. If another tab saved first, the adapter reports the newer revision and the editor enters a recovery flow instead of clobbering data.
type CommitResult =
| { ok: true; revision: number }
| { ok: false; reason: "stale" | "quota" | "error" | "closed" };On a stale failure the adapter does not advance the editor's revision; the editor reloads or explicitly resolves the conflict.
WorkspaceEnvelope
The persisted unit — one JSON document per workspace:
interface WorkspaceEnvelope {
workspaceSchemaVersion: "1.0.0";
id: string;
name: string;
savedAt: number;
storageRevision?: number;
notes: NoteRecord[];
folders: FolderRecord[];
}NoteRecord— id, title, folderId, document, timestamps, trashed flag.FolderRecord— id, name, parentId, timestamps, trashed flag.
WORKSPACE_SCHEMA_VERSION is "1.0.0", versioned independently from the package.
Default workspace id
Without a custom id, the workspace is keyed by page location: defaultWorkspaceId(targetId) → "<pathname>#<targetId|default>". So different pages can host different workspaces, and the same page shares one.
Assets
Images can be stored as asset:<id> references resolved through the adapter:
await state.saveAsset(asset); // WorkspaceAsset { id, mime, bytes, ... }
const url = await state.assetObjectUrl(id); // object URL for renderingBackups include assets (WorkspaceBackup), and encodeBackupForExport produces base64-safe JSON for downloads. resolveDocumentAssets(doc, loadAsset) inlines assets as data URLs for portable exports.
Asset reads return independent byte buffers in both built-in adapters. Object URLs are shared per asset within a workspace session and revoked on restore or destruction. Backups capture a single document snapshot before collecting its referenced assets, so edits made during collection do not produce a mismatched backup.
After a successful restore, state.workspaceId reports the restored storage key. The previous active note and folder are cleared; WorkspaceController.restoreBackup opens the first restored note with a fresh undo history. A failed restore retains the previous session.
Server-backed storage
Implement the exported StorageAdapter interface against your backend. A revision-checked commit can use this HTTP contract: the server atomically compares expectedRevision, returns HTTP 409 for conflicts, and returns { revision } on success. Add the remaining interface members for workspace/asset reads, asset writes, subscription and connection cleanup.
import type { CommitResult, WorkspaceEnvelope } from "@bookklik/ezynota";
async function commit(id: string, envelope: WorkspaceEnvelope, expectedRevision: number): Promise<CommitResult> {
const res = await fetch(`/api/workspaces/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ envelope, expectedRevision }),
});
if (res.status === 409) return { ok: false, reason: "stale" };
if (res.status === 507) return { ok: false, reason: "quota" };
if (!res.ok) return { ok: false, reason: "error" };
const data: { revision?: number } = await res.json();
return typeof data.revision === "number" && Number.isInteger(data.revision) && data.revision > expectedRevision
? { ok: true, revision: data.revision }
: { ok: false, reason: "error" };
}WARNING
Respect storageRevision on commit: if the server holds a newer revision, return a conflict result rather than overwriting. The editor's recovery UI depends on this to prevent silent data loss.