Skip to content

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 ​

ts
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 ​

ts
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.

ts
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:

ts
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:

ts
await state.saveAsset(asset);          // WorkspaceAsset { id, mime, bytes, ... }
const url = await state.assetObjectUrl(id); // object URL for rendering

Backups 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.

ts
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.

Released under the MIT License. Zero runtime dependencies.