mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* 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
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
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);
|
|
}
|