Skip to content

Latest commit

 

History

History
536 lines (414 loc) · 17.8 KB

File metadata and controls

536 lines (414 loc) · 17.8 KB

Persistence Integrations

Travels persistence is storage-agnostic. The only value that needs to be stored is the versioned snapshot returned by travels.serialize(). Restore it with Travels.deserialize(...), then pass the validated history back to createTravels(...).

This guide shows production-oriented adapters for:

  • Dexie.js
  • idb
  • localForage
  • localspace

The API notes below were checked against the current docs and npm package metadata on 2026-05-15:

Library Checked package Storage model Best fit
Dexie.js dexie@4.4.2 IndexedDB tables, indexes, transactions, rich queries Multiple documents, searchable snapshot catalogs, custom cleanup queries
idb idb@8.0.3 Thin promise wrapper over native IndexedDB Small IndexedDB adapter with explicit schema control
localForage localforage@1.10.0 Async key-value API over IndexedDB and localStorage, with a legacy WebSQL driver for old browsers Simple browser key-value persistence with legacy compatibility
localspace localspace@1.2.0 localForage-compatible async key-value API with IndexedDB/localStorage drivers, batch APIs, transactions, plugins TypeScript-first key-value persistence, batching, plugin-driven TTL/compression/encryption

The Playwright e2e suite exercises these adapter patterns against the real browser storage implementations. The fixture uses test-specific database names, but keeps the save, load, transaction, and cleanup semantics aligned with the examples below.

Snapshot Contract

Use the whole serialized snapshot as a single storage record. It contains the state, patch history, current position, optional metadata, and the Travels schema version.

import {
  createTravels,
  Travels,
  TravelsPersistenceError,
  TRAVELS_HISTORY_SCHEMA_VERSION,
  type TravelsSerializedHistory,
} from 'travels';

type DocumentState = {
  title: string;
  blocks: Array<{ id: string; text: string }>;
};

const createDefaultDocument = (): DocumentState => ({
  title: 'Untitled',
  blocks: [],
});

const createEmptySnapshot = (): TravelsSerializedHistory<DocumentState> => ({
  version: TRAVELS_HISTORY_SCHEMA_VERSION,
  state: createDefaultDocument(),
  patches: { patches: [], inversePatches: [] },
  position: 0,
});

function restoreTravels(raw: unknown) {
  const history = Travels.deserialize<DocumentState>(
    raw ?? createEmptySnapshot(),
    {
      fallback: createEmptySnapshot,
      onError(error) {
        if (error instanceof TravelsPersistenceError) {
          console.warn('Ignoring invalid persisted history:', error.code);
        }
      },
    }
  );

  return createTravels(history.state, {
    history,
    maxHistory: 100,
    strictInitialPatches: true,
  });
}

For durable persistence, keep Travels state JSON-compatible: plain objects, arrays, strings, numbers, booleans, and null. IndexedDB can store richer values, but JSON Patch replay and cross-environment migrations are easiest when state uses the same durable subset.

The adapter examples below reuse the DocumentState, restoreTravels(...), and attachAutoSave(...) definitions from this section.

Auto-Save Pattern

Storage writes should usually be debounced. This keeps rapid editing from writing one snapshot per keystroke while still persisting the latest committed Travels state.

function attachAutoSave(
  travels: ReturnType<typeof restoreTravels>,
  saveSnapshot: (
    snapshot: TravelsSerializedHistory<DocumentState>
  ) => Promise<void>,
  debounceMs = 200
) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let pendingSave = Promise.resolve();

  const clearTimer = () => {
    if (timer) {
      clearTimeout(timer);
      timer = undefined;
    }
  };

  const flush = async () => {
    clearTimer();

    const snapshot = travels.serialize();

    pendingSave = pendingSave
      .catch(() => undefined)
      .then(() => saveSnapshot(snapshot));

    await pendingSave;
  };

  const unsubscribe = travels.subscribe(() => {
    clearTimer();

    timer = setTimeout(() => {
      void flush().catch((error) => {
        console.error('Failed to persist Travels history:', error);
      });
    }, debounceMs);
  });

  return {
    flush,
    async dispose(options: { flush?: boolean } = { flush: true }) {
      clearTimer();
      unsubscribe();

      if (options.flush !== false) {
        await flush();
      }
    },
  };
}

Use flush() before route transitions or other app-controlled shutdown points. Use dispose() when the Travels instance is no longer active; it clears any pending debounce, removes the subscription, and flushes the latest snapshot by default.

If edits must survive a tab close, also call travels.serialize() from your page lifecycle handler and run a best-effort final save, or call the returned flush() method when the page is still active. Keep that path small: browser shutdown events are not reliable for long async work.

Dexie.js

Dexie.js is a high-level IndexedDB wrapper with table APIs, schema versioning, indexes, transactions, and bulk operations. It is a good fit when an app stores many persisted timelines and needs to query them by document, timestamp, owner, or project.

Install:

npm install dexie

Adapter:

import Dexie, { type Table } from 'dexie';
import type { TravelsSerializedHistory } from 'travels';

type SnapshotRow = {
  key: string;
  value: TravelsSerializedHistory<DocumentState>;
  updatedAt: number;
};

type SnapshotAuditRow = {
  id?: number;
  key: string;
  action: 'save';
  updatedAt: number;
};

class TravelsDexieDB extends Dexie {
  snapshots!: Table<SnapshotRow, string>;
  snapshotAudit!: Table<SnapshotAuditRow, number>;

  constructor() {
    super('travels');

    this.version(1).stores({
      snapshots: 'key, updatedAt',
      snapshotAudit: '++id, key, updatedAt',
    });
  }
}

const db = new TravelsDexieDB();
const SNAPSHOT_KEY = 'document:main';

async function loadSnapshotFromDexie() {
  const row = await db.snapshots.get(SNAPSHOT_KEY);
  return row?.value ?? null;
}

async function saveSnapshotToDexie(
  snapshot: TravelsSerializedHistory<DocumentState>
) {
  await db.snapshots.put({
    key: SNAPSHOT_KEY,
    value: snapshot,
    updatedAt: Date.now(),
  });
}

async function initDexiePersistence() {
  const travels = restoreTravels(await loadSnapshotFromDexie());
  const persistence = attachAutoSave(travels, saveSnapshotToDexie);

  return { travels, persistence };
}

Use a transaction when one user action updates the snapshot and a related table:

async function saveDexieSnapshotWithRelatedRows(
  travels: ReturnType<typeof restoreTravels>
) {
  const updatedAt = Date.now();

  await db.transaction('rw', db.snapshots, db.snapshotAudit, async () => {
    await db.snapshots.put({
      key: SNAPSHOT_KEY,
      value: travels.serialize(),
      updatedAt,
    });

    await db.snapshotAudit.add({
      key: SNAPSHOT_KEY,
      action: 'save',
      updatedAt,
    });
  });
}

Use the updatedAt index when pruning old snapshot rows:

async function deleteDexieSnapshotsOlderThan(cutoff: number) {
  await db.snapshots.where('updatedAt').below(cutoff).delete();
}

Dexie-specific notes:

  • Use version(...).stores(...) for schema evolution.
  • Keep the serialized Travels snapshot in one row when you need atomic restore.
  • Add secondary indexes such as updatedAt only for values you actually query.
  • Prefer Dexie when persistence is part of a broader IndexedDB data model, not just a single key-value record.

idb

idb is a small promise-based wrapper that mostly mirrors native IndexedDB. It is a good fit when you want IndexedDB's object stores, indexes, and transaction semantics without a larger abstraction.

Install:

npm install idb

Adapter:

import { openDB, type DBSchema } from 'idb';
import type { TravelsSerializedHistory } from 'travels';

interface TravelsPersistenceDB extends DBSchema {
  snapshots: {
    key: string;
    value: {
      key: string;
      value: TravelsSerializedHistory<DocumentState>;
      updatedAt: number;
    };
    indexes: {
      'by-updatedAt': number;
    };
  };
}

const SNAPSHOT_KEY = 'document:main';

const dbPromise = openDB<TravelsPersistenceDB>('travels', 1, {
  upgrade(db) {
    const store = db.createObjectStore('snapshots', {
      keyPath: 'key',
    });

    store.createIndex('by-updatedAt', 'updatedAt');
  },
});

async function loadSnapshotFromIdb() {
  const db = await dbPromise;
  const row = await db.get('snapshots', SNAPSHOT_KEY);

  return row?.value ?? null;
}

async function saveSnapshotToIdb(
  snapshot: TravelsSerializedHistory<DocumentState>
) {
  const db = await dbPromise;
  const tx = db.transaction('snapshots', 'readwrite');

  await tx.store.put({
    key: SNAPSHOT_KEY,
    value: snapshot,
    updatedAt: Date.now(),
  });

  await tx.done;
}

async function deleteIdbSnapshotsOlderThan(cutoff: number) {
  const db = await dbPromise;
  const tx = db.transaction('snapshots', 'readwrite');
  const index = tx.store.index('by-updatedAt');
  let cursor = await index.openCursor(IDBKeyRange.upperBound(cutoff, true));

  while (cursor) {
    await cursor.delete();
    cursor = await cursor.continue();
  }

  await tx.done;
}

async function initIdbPersistence() {
  const travels = restoreTravels(await loadSnapshotFromIdb());
  const persistence = attachAutoSave(travels, saveSnapshotToIdb);

  return { travels, persistence };
}

idb-specific notes:

  • openDB(name, version, { upgrade }) is where object stores and indexes are created or migrated.
  • Use db.get(...), db.put(...), db.delete(...), and db.clear(...) for single-store shortcuts.
  • Use explicit transactions and await tx.done when a save includes multiple operations.
  • Do not wait on unrelated async work, such as fetch(...), in the middle of an active IndexedDB transaction. Transactions can auto-close while waiting.

localForage

localForage exposes an async localStorage-like API with getItem, setItem, removeItem, clear, keys, and iterate. Its documented default driver order is IndexedDB, WebSQL, then localStorage, but WebSQL is obsolete in modern browsers. Treat WebSQL as legacy migration context and design new persistence around IndexedDB.

Install:

npm install localforage

Adapter:

import localforage from 'localforage';
import type { TravelsSerializedHistory } from 'travels';

const SNAPSHOT_KEY = 'document:main';

const store = localforage.createInstance({
  name: 'travels',
  storeName: 'snapshots',
});

async function loadSnapshotFromLocalForage() {
  await store.ready();

  return store.getItem<TravelsSerializedHistory<DocumentState>>(SNAPSHOT_KEY);
}

async function saveSnapshotToLocalForage(
  snapshot: TravelsSerializedHistory<DocumentState>
) {
  await store.setItem(SNAPSHOT_KEY, snapshot);
}

async function initLocalForagePersistence() {
  const travels = restoreTravels(await loadSnapshotFromLocalForage());
  const persistence = attachAutoSave(travels, saveSnapshotToLocalForage);

  return { travels, persistence };
}

localForage-specific notes:

  • Call config(...) before any data API call, or prefer createInstance(...) for isolated stores.
  • setItem(...) returns the saved value; getItem(...) returns null when a key does not exist.
  • undefined is not a durable stored value; use null for intentional empty values.
  • clear() removes everything in the current store. Use removeItem(key) for a single Travels snapshot.
  • localForage is a simple key-value adapter. If you need atomic multi-key writes, use IndexedDB directly, Dexie.js, idb, or localspace with IndexedDB as the active driver.

localspace

localspace keeps localForage-style storage methods while adding TypeScript-first APIs, batch operations, transaction helpers, plugins, and explicit modern drivers. It supports IndexedDB and localStorage in the browser; WebSQL is not supported. The in-memory driver is available only when explicitly added as a fallback and loses data on reload.

Install:

npm install localspace

Adapter:

import localspace from 'localspace';
import type { TravelsSerializedHistory } from 'travels';

const SNAPSHOT_KEY = 'document:main';

const store = localspace.createInstance({
  name: 'travels',
  storeName: 'snapshots',
  driver: [localspace.INDEXEDDB, localspace.LOCALSTORAGE],
});

async function loadSnapshotFromLocalspace() {
  await store.ready();

  return store.getItem<TravelsSerializedHistory<DocumentState>>(SNAPSHOT_KEY);
}

async function saveSnapshotToLocalspace(
  snapshot: TravelsSerializedHistory<DocumentState>
) {
  await store.setItem(SNAPSHOT_KEY, snapshot);
}

async function initLocalspacePersistence() {
  const travels = restoreTravels(await loadSnapshotFromLocalspace());
  const persistence = attachAutoSave(travels, saveSnapshotToLocalspace);

  return { travels, persistence };
}

Use runTransaction(...) or batch APIs when saving related records together. This is transactional with the IndexedDB driver; localStorage fallback runs the operations sequentially but cannot provide IndexedDB-style atomic commits:

async function saveLocalspaceSnapshotWithRelatedRows(
  travels: ReturnType<typeof restoreTravels>
) {
  await store.runTransaction('readwrite', async (tx) => {
    await tx.set(SNAPSHOT_KEY, travels.serialize());
    await tx.set(`${SNAPSHOT_KEY}:updatedAt`, Date.now());
  });
}

For multiple timelines:

async function saveMultipleLocalspaceSnapshots(
  entries: Array<{
    key: string;
    snapshot: TravelsSerializedHistory<DocumentState>;
  }>
) {
  await store.setItems(
    entries.map(({ key, snapshot }) => ({ key, value: snapshot }))
  );
}

localspace-specific notes:

  • Prefer [localspace.INDEXEDDB, localspace.LOCALSTORAGE] for durable browser fallback.
  • Add localspace.MEMORY only when runtime-only fallback is acceptable.
  • Use setItems(...), getItems(...), and removeItems(...) for batch workloads. With localStorage fallback, batches are not atomic.
  • Add coalesceWrites only for bursty multi-key writes. For a single debounced SNAPSHOT_KEY, it is usually unnecessary.
  • Set pluginErrorPolicy: 'strict' when using encryption so persistence errors do not get swallowed.
  • Call destroy() when disposing plugin-heavy instances.

Choosing an Adapter

Requirement Recommended adapter
One snapshot per app, minimal API localForage or localspace
One snapshot per app, strict IndexedDB semantics idb
Many documents, indexes, cleanup queries Dexie.js
Batch writes and localForage-compatible migration path localspace
Existing localForage codebase localForage first; localspace when adopting TypeScript-first APIs or batches
Avoid WebSQL and keep modern browser drivers explicit localspace, idb, or Dexie.js

Migration and Corruption Recovery

Use migrate when the stored shape predates Travels' current serialized history schema:

type LegacyDocumentSnapshot = {
  version: 0;
  state: DocumentState;
  history: TravelsSerializedHistory<DocumentState>['patches'];
  cursor: number;
};

const history = Travels.deserialize<DocumentState>(stored, {
  migrate(snapshot) {
    if (
      snapshot &&
      typeof snapshot === 'object' &&
      (snapshot as { version?: unknown }).version === 0
    ) {
      const legacy = snapshot as LegacyDocumentSnapshot;

      return {
        version: TRAVELS_HISTORY_SCHEMA_VERSION,
        state: legacy.state,
        patches: legacy.history,
        position: legacy.cursor,
      };
    }

    return snapshot;
  },
  fallback: createEmptySnapshot,
});

Recovery rules:

  • Always provide fallback for browser startup paths. A corrupted local snapshot should not make the app unusable.
  • Use onError to log the stable TravelsPersistenceError.code.
  • Keep storage keys namespaced, for example travels:<app>:<documentId>.
  • If persistence size matters, compress the serialized snapshot before storage and decompress before Travels.deserialize(...).
  • If state contains non-JSON values such as Date, Map, or Set, add an application codec before writing and after reading. Prefer timestamps, records, and arrays for durable state.

External References