mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): preserve external file conflicts (#2990)
* fix(studio): drain pending edits before reload * fix(studio): address drain review feedback (#2989) - prioritize conflicts and clear recovered DOM queue errors - cover delayed blur effects and missing drain branches - document stacked consumers and extend write-token retention * test(studio): satisfy drain audit gate (#2989) - share the editor-save hook harness across drain regressions - extract settled failure inspection from the drain loop * feat(studio): preserve external file conflicts * fix(studio): isolate retry write receipts * test(studio): cover external conflict recovery safety
This commit is contained in:
@@ -162,6 +162,17 @@
|
||||
"produceDrawElementFrame",
|
||||
],
|
||||
},
|
||||
// External-conflict persistence is the #2990 stack boundary. The coordinator
|
||||
// consumes these exports in child PR #2991; keep the primitive independently reviewable.
|
||||
{
|
||||
"file": "packages/studio/src/utils/externalConflictStorage.ts",
|
||||
"exports": [
|
||||
"persistExternalConflictSnapshot",
|
||||
"persistExternalFailureSnapshot",
|
||||
"loadExternalConflictSnapshot",
|
||||
"deleteExternalConflictSnapshot",
|
||||
],
|
||||
},
|
||||
// CLI command files: every command exports a const `examples` per the
|
||||
// convention documented in CLAUDE.md. This is a namespace barrel, not a
|
||||
// collision.
|
||||
|
||||
@@ -343,6 +343,7 @@
|
||||
"@types/react-dom": "19",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"postcss": "^8.4.0",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"tailwindcss": "^3.4.0",
|
||||
@@ -1487,6 +1488,8 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
|
||||
|
||||
"fallow": ["fallow@2.75.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@fallow-cli/darwin-arm64": "2.75.0", "@fallow-cli/darwin-x64": "2.75.0", "@fallow-cli/linux-arm64-gnu": "2.75.0", "@fallow-cli/linux-arm64-musl": "2.75.0", "@fallow-cli/linux-x64-gnu": "2.75.0", "@fallow-cli/linux-x64-musl": "2.75.0", "@fallow-cli/win32-arm64-msvc": "2.75.0", "@fallow-cli/win32-x64-msvc": "2.75.0" }, "bin": { "fallow": "bin/fallow", "fallow-lsp": "bin/fallow-lsp", "fallow-mcp": "bin/fallow-mcp" } }, "sha512-0/2cquNI/cDLP/LzcCbkwI4hMzkX4tE0VY3/69n3PBBeqFpbM2oai+2Cb0sB8dXB8MDUGPVoPJjDW5GiUo7a1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"@types/react-dom": "19",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"postcss": "^8.4.0",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"tailwindcss": "^3.4.0",
|
||||
|
||||
@@ -27,9 +27,13 @@ export function FileManagerProvider({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveRafRef,
|
||||
flushPendingSourceSave,
|
||||
discardPendingSourceSave,
|
||||
getPendingSourceCandidate,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
overwriteExternalConflict,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
@@ -67,9 +71,13 @@ export function FileManagerProvider({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveRafRef,
|
||||
flushPendingSourceSave,
|
||||
discardPendingSourceSave,
|
||||
getPendingSourceCandidate,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
overwriteExternalConflict,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
@@ -101,9 +109,13 @@ export function FileManagerProvider({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveRafRef,
|
||||
flushPendingSourceSave,
|
||||
discardPendingSourceSave,
|
||||
getPendingSourceCandidate,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
overwriteExternalConflict,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
|
||||
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
|
||||
useEditorSave: () => ({
|
||||
saveRafRef: { current: null },
|
||||
handleContentChange: vi.fn(),
|
||||
getPendingCandidate: vi.fn(() => null),
|
||||
flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
|
||||
discardPendingSave: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useFileManager } from "./useFileManager";
|
||||
import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
|
||||
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function useTestFileManager(projectId: string) {
|
||||
return useFileManager({
|
||||
projectId,
|
||||
showToast: vi.fn(),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTestFileManager(projectId = "project-a") {
|
||||
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
||||
function Probe() {
|
||||
captured.manager = useTestFileManager(projectId);
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe />));
|
||||
const manager = captured.manager;
|
||||
if (!manager) throw new Error("file manager did not render");
|
||||
return { manager, root };
|
||||
}
|
||||
|
||||
async function mountOverwriteRequest(response: Response) {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method !== "PUT") {
|
||||
throw new Error("overwrite unexpectedly performed a preflight read");
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { ...(await mountTestFileManager()), fetchMock };
|
||||
}
|
||||
|
||||
function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
|
||||
return new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion,
|
||||
currentContent,
|
||||
attemptedContent: "STUDIO",
|
||||
});
|
||||
}
|
||||
|
||||
describe("useFileManager project ownership", () => {
|
||||
afterEach(() => {
|
||||
resetStudioWriteTokens();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
|
||||
|
||||
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
||||
function Probe({ projectId }: { projectId: string }) {
|
||||
captured.manager = useFileManager({
|
||||
projectId,
|
||||
showToast: vi.fn(),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
});
|
||||
captured.manager = useTestFileManager(projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("uses a fresh write token when a lost response retries a committed save", async () => {
|
||||
vi.useFakeTimers();
|
||||
let putAttempt = 0;
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (!init?.method) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ content: "BEFORE", version: "v1" }),
|
||||
} as Response);
|
||||
}
|
||||
putAttempt += 1;
|
||||
if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
|
||||
return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { manager, root } = await mountTestFileManager();
|
||||
await manager.readProjectFile("index.html");
|
||||
|
||||
const write = manager.writeProjectFile("index.html", "AFTER");
|
||||
await vi.runAllTimersAsync();
|
||||
await write;
|
||||
|
||||
const writeTokens = fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === "PUT")
|
||||
.map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
|
||||
expect(writeTokens).toHaveLength(2);
|
||||
expect(writeTokens[0]).toBeTruthy();
|
||||
expect(writeTokens[1]).not.toBe(writeTokens[0]);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("overwrites the exact external content version with an If-Match precondition", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: true,
|
||||
json: async () => ({ version: "v3" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict("v2", "EXTERNAL");
|
||||
|
||||
await manager.overwriteExternalConflict(conflict);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
|
||||
expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
|
||||
expect(headers.get("If-None-Match")).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict("v2", null);
|
||||
|
||||
await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
|
||||
name: "StudioFileConflictError",
|
||||
currentVersion: "v3",
|
||||
currentContent: "THIRD PARTY",
|
||||
attemptedContent: "STUDIO",
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("If-Match")).toBe("v2");
|
||||
expect(headers.get("If-None-Match")).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("uses create-only semantics when the conflicted file was deleted", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: true,
|
||||
json: async () => ({ version: "v1" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict(null, null);
|
||||
|
||||
await manager.overwriteExternalConflict(conflict);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("If-Match")).toBeNull();
|
||||
expect(headers.get("If-None-Match")).toBe("*");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
StudioFileConflictError,
|
||||
StudioSaveNetworkError,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
|
||||
import {
|
||||
createStudioWriteToken,
|
||||
markStudioWriteToken,
|
||||
studioExpectedFileVersion,
|
||||
} from "../utils/studioFileVersion";
|
||||
import { useFileTree } from "./useFileTree";
|
||||
import { useEditorSave } from "./useEditorSave";
|
||||
|
||||
@@ -117,8 +121,11 @@ export function useFileManager({
|
||||
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
||||
}
|
||||
}
|
||||
const writeToken = createStudioWriteToken();
|
||||
await retryStudioSave(async () => {
|
||||
// Each request gets its own receipt identity. If a committed request loses its response,
|
||||
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
||||
const writeToken = createStudioWriteToken();
|
||||
markStudioWriteToken(writeToken);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
@@ -191,7 +198,7 @@ export function useFileManager({
|
||||
|
||||
// ── Editor save (debounced content change) ──
|
||||
|
||||
const { saveRafRef, handleContentChange } = useEditorSave({
|
||||
const editorSave = useEditorSave({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
readProjectFile,
|
||||
@@ -201,6 +208,24 @@ export function useFileManager({
|
||||
setRefreshKey,
|
||||
showToast,
|
||||
});
|
||||
const { saveRafRef, handleContentChange } = editorSave;
|
||||
|
||||
const overwriteExternalConflict = useCallback(
|
||||
async (conflict: StudioFileConflictError) => {
|
||||
if (conflict.currentContent != null) {
|
||||
await writeProjectFile(
|
||||
conflict.filePath,
|
||||
conflict.attemptedContent,
|
||||
conflict.currentContent,
|
||||
);
|
||||
} else {
|
||||
fileVersions.set(conflict.filePath, conflict.currentVersion);
|
||||
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
|
||||
}
|
||||
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
|
||||
},
|
||||
[fileVersions, updateEditingFileContent, writeProjectFile],
|
||||
);
|
||||
|
||||
// ── File select ──
|
||||
|
||||
@@ -491,11 +516,15 @@ export function useFileManager({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveRafRef,
|
||||
flushPendingSourceSave: editorSave.flushPendingSave,
|
||||
discardPendingSourceSave: editorSave.discardPendingSave,
|
||||
getPendingSourceCandidate: editorSave.getPendingCandidate,
|
||||
importedFontAssetsRef,
|
||||
|
||||
// Core I/O
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
overwriteExternalConflict,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMemoryExternalConflictStorage,
|
||||
deleteExternalConflictSnapshot,
|
||||
loadExternalConflictSnapshot,
|
||||
persistExternalConflictSnapshot,
|
||||
type ExternalConflictSnapshot,
|
||||
} from "./externalConflictStorage";
|
||||
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
||||
|
||||
describe("external conflict snapshots", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("indexedDB", new IDBFactory());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("durably records both complete file versions before resolution", async () => {
|
||||
const storage = createMemoryExternalConflictStorage();
|
||||
const snapshot: ExternalConflictSnapshot = {
|
||||
kind: "conflict",
|
||||
projectId: "project-a",
|
||||
filePath: "index.html",
|
||||
externalVersion: "v2",
|
||||
externalContent: "external full file",
|
||||
studioContent: "studio full file",
|
||||
createdAt: 100,
|
||||
};
|
||||
|
||||
await storage.set(snapshot);
|
||||
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
|
||||
await storage.delete("project-a", "index.html");
|
||||
expect(await storage.get("project-a", "index.html")).toBeNull();
|
||||
});
|
||||
|
||||
it("records a failed final Studio candidate for remount recovery", async () => {
|
||||
const storage = createMemoryExternalConflictStorage();
|
||||
const snapshot: ExternalConflictSnapshot = {
|
||||
kind: "failed",
|
||||
projectId: "project-a",
|
||||
filePath: "index.html",
|
||||
externalVersion: null,
|
||||
externalContent: null,
|
||||
studioContent: "recover me",
|
||||
failureMessage: "network unavailable",
|
||||
createdAt: 101,
|
||||
};
|
||||
|
||||
await storage.set(snapshot);
|
||||
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("persists, loads, and deletes a snapshot through the production IndexedDB adapter", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external full file",
|
||||
attemptedContent: "studio full file",
|
||||
});
|
||||
|
||||
await persistExternalConflictSnapshot("indexeddb-project", conflict);
|
||||
await expect(loadExternalConflictSnapshot("indexeddb-project", "index.html")).resolves.toEqual({
|
||||
kind: "conflict",
|
||||
projectId: "indexeddb-project",
|
||||
filePath: "index.html",
|
||||
externalVersion: "v2",
|
||||
externalContent: "external full file",
|
||||
studioContent: "studio full file",
|
||||
createdAt: 1234,
|
||||
});
|
||||
await deleteExternalConflictSnapshot("indexeddb-project", "index.html");
|
||||
await expect(
|
||||
loadExternalConflictSnapshot("indexeddb-project", "index.html"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects persistence when IndexedDB is unavailable", async () => {
|
||||
vi.stubGlobal("indexedDB", undefined);
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
|
||||
await expect(persistExternalConflictSnapshot("project-a", conflict)).rejects.toThrow(
|
||||
"IndexedDB is unavailable; conflict recovery snapshot was not saved",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { StudioFileConflictError } from "./studioSaveDiagnostics";
|
||||
|
||||
interface ExternalRecoverySnapshotBase {
|
||||
projectId: string;
|
||||
filePath: string;
|
||||
externalVersion: string | null;
|
||||
externalContent: string | null;
|
||||
studioContent: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ExternalConflictSnapshot =
|
||||
| (ExternalRecoverySnapshotBase & { kind: "conflict" })
|
||||
| (ExternalRecoverySnapshotBase & { kind: "failed"; failureMessage: string });
|
||||
|
||||
export interface ExternalConflictStorage {
|
||||
get(projectId: string, filePath: string): Promise<ExternalConflictSnapshot | null>;
|
||||
set(snapshot: ExternalConflictSnapshot): Promise<void>;
|
||||
delete(projectId: string, filePath: string): Promise<void>;
|
||||
}
|
||||
|
||||
const DB_NAME = "hyperframes-studio-external-conflicts";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "file-conflicts";
|
||||
|
||||
function key(projectId: string, filePath: string): string {
|
||||
return `${projectId}\0${filePath}`;
|
||||
}
|
||||
|
||||
export function createMemoryExternalConflictStorage(): ExternalConflictStorage {
|
||||
const snapshots = new Map<string, ExternalConflictSnapshot>();
|
||||
return {
|
||||
async get(projectId, filePath) {
|
||||
const snapshot = snapshots.get(key(projectId, filePath));
|
||||
return snapshot ? structuredClone(snapshot) : null;
|
||||
},
|
||||
async set(snapshot) {
|
||||
snapshots.set(key(snapshot.projectId, snapshot.filePath), structuredClone(snapshot));
|
||||
},
|
||||
async delete(projectId, filePath) {
|
||||
snapshots.delete(key(projectId, filePath));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!globalThis.indexedDB) {
|
||||
reject(new Error("IndexedDB is unavailable; conflict recovery snapshot was not saved"));
|
||||
return;
|
||||
}
|
||||
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error ?? new Error("Failed to open conflict storage"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function withStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
const request = callback(transaction.objectStore(STORE_NAME));
|
||||
let result!: T;
|
||||
request.onerror = () => reject(request.error ?? new Error("Conflict storage failed"));
|
||||
request.onsuccess = () => {
|
||||
result = request.result;
|
||||
};
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(result);
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
db.close();
|
||||
reject(transaction.error ?? new Error("Conflict storage transaction failed"));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
db.close();
|
||||
reject(transaction.error ?? new Error("Conflict storage transaction was aborted"));
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createIndexedDbExternalConflictStorage(): ExternalConflictStorage {
|
||||
return {
|
||||
async get(projectId, filePath) {
|
||||
return (
|
||||
(await withStore<ExternalConflictSnapshot | undefined>("readonly", (store) =>
|
||||
store.get(key(projectId, filePath)),
|
||||
)) ?? null
|
||||
);
|
||||
},
|
||||
async set(snapshot) {
|
||||
await withStore<IDBValidKey>("readwrite", (store) =>
|
||||
store.put(snapshot, key(snapshot.projectId, snapshot.filePath)),
|
||||
);
|
||||
},
|
||||
async delete(projectId, filePath) {
|
||||
await withStore<undefined>("readwrite", (store) => store.delete(key(projectId, filePath)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const indexedDbStorage = createIndexedDbExternalConflictStorage();
|
||||
|
||||
/**
|
||||
* Persist a conflict before offering destructive recovery actions.
|
||||
* Callers must await this promise: rejection means the Studio draft was not saved durably and
|
||||
* must remain available in memory while the storage failure is surfaced to the user.
|
||||
*/
|
||||
export async function persistExternalConflictSnapshot(
|
||||
projectId: string,
|
||||
conflict: StudioFileConflictError,
|
||||
): Promise<void> {
|
||||
await indexedDbStorage.set({
|
||||
kind: "conflict",
|
||||
projectId,
|
||||
filePath: conflict.filePath,
|
||||
externalVersion: conflict.currentVersion,
|
||||
externalContent: conflict.currentContent,
|
||||
studioContent: conflict.attemptedContent,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the final Studio candidate after its file write fails.
|
||||
* Callers must await this promise: rejection means the Studio draft was not saved durably and
|
||||
* must remain available in memory while the storage failure is surfaced to the user.
|
||||
*/
|
||||
export async function persistExternalFailureSnapshot(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
studioContent: string,
|
||||
externalVersion: string | null,
|
||||
externalContent: string | null,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
await indexedDbStorage.set({
|
||||
kind: "failed",
|
||||
projectId,
|
||||
filePath,
|
||||
externalVersion,
|
||||
externalContent,
|
||||
studioContent,
|
||||
failureMessage: error instanceof Error ? error.message : String(error),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadExternalConflictSnapshot(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
): Promise<ExternalConflictSnapshot | null> {
|
||||
return indexedDbStorage.get(projectId, filePath);
|
||||
}
|
||||
|
||||
export async function deleteExternalConflictSnapshot(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await indexedDbStorage.delete(projectId, filePath);
|
||||
}
|
||||
Reference in New Issue
Block a user