marmar-context / web / context / src / workspace-store.ts
workspace-store.ts
Raw
import { parseWorkspace, type Workspace } from "./workspace";

export type SavedWorkspace = {
  revision: string | null;
  workspace: Workspace | null;
};
type RecordValue = { revision: string; payload: string };
export class WorkspaceConflict extends Error {
  constructor() {
    super(
      "Newer work was saved in another tab. This tab has not overwritten it."
    );
  }
}
export function workspaceContent(workspace: Workspace) {
  const { savedAt: _savedAt, ...content } = workspace;
  return JSON.stringify(content, (_key, value) =>
    value && typeof value === "object" && !Array.isArray(value)
      ? Object.fromEntries(
          Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
        )
      : value
  );
}
export function nextRecord(
  current: RecordValue | undefined,
  workspace: Workspace,
  expected: string | null
): RecordValue {
  if ((current?.revision ?? null) !== expected) throw new WorkspaceConflict();
  if (
    current &&
    workspaceContent(parseWorkspace(current.payload)) ===
      workspaceContent(workspace)
  )
    return current;
  // Validate before committing; never replace a good saved copy with unreadable data.
  const clean = parseWorkspace(JSON.stringify(workspace));
  return { revision: crypto.randomUUID(), payload: JSON.stringify(clean) };
}

// IndexedDB read/write transactions serialize competing tabs. A stale revision
// cannot replace newer work, even if both tabs save at the same instant.
export class WorkspaceStore {
  private database: Promise<IDBDatabase> | null = null;
  constructor(private name = "marmar-context-workspaces") {}
  private open() {
    if (!this.database) {
      this.database = new Promise<IDBDatabase>((resolve, reject) => {
        const request = indexedDB.open(this.name, 1);
        request.onupgradeneeded = () =>
          request.result.createObjectStore("workspace");
        request.onerror = () => reject(request.error);
        request.onblocked = () =>
          reject(
            new Error(
              "Browser storage is busy in another tab. Close the older tab and retry saving."
            )
          );
        request.onsuccess = () => {
          const db = request.result;
          db.onversionchange = () => {
            db.close();
            this.database = null;
          };
          resolve(db);
        };
      }).catch((error) => {
        this.database = null;
        throw error;
      });
    }
    return this.database;
  }
  async read(): Promise<SavedWorkspace> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction("workspace", "readonly");
      const request = tx.objectStore("workspace").get("current");
      tx.oncomplete = () => {
        try {
          const value = request.result as RecordValue | undefined;
          resolve(
            value
              ? {
                  revision: value.revision,
                  workspace: parseWorkspace(value.payload),
                }
              : { revision: null, workspace: null }
          );
        } catch (e) {
          reject(e);
        }
      };
      tx.onabort = () =>
        reject(tx.error ?? new Error("Browser storage could not be read."));
      tx.onerror = () => reject(tx.error);
    });
  }
  async save(workspace: Workspace, expected: string | null): Promise<string> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction("workspace", "readwrite", {
        durability: "strict",
      });
      const store = tx.objectStore("workspace");
      const request = store.get("current");
      let result: RecordValue;
      let failure: unknown;
      request.onsuccess = () => {
        try {
          result = nextRecord(request.result, workspace, expected);
          if (result !== request.result) store.put(result, "current");
        } catch (e) {
          failure = e;
          tx.abort();
        }
      };
      tx.oncomplete = () => resolve(result.revision);
      tx.onabort = () =>
        reject(
          failure ?? tx.error ?? new Error("Browser save was interrupted.")
        );
      tx.onerror = () => {
        failure ??= tx.error;
      };
    });
  }
}