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;
};
});
}
}