mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +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:
@@ -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