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:
Miguel Ángel
2026-08-04 21:19:42 +00:00
committed by GitHub
parent 4713138544
commit b30a23402e
8 changed files with 465 additions and 10 deletions
@@ -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);
}