Files
hyperframes/packages/studio/src/utils/domEditSaveQueue.ts
T
Miguel Ángel 5f12e692d5 fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
2026-06-12 22:02:17 -04:00

88 lines
2.2 KiB
TypeScript

import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
interface DomEditSaveQueueOpenEvent {
consecutiveFailures: number;
errorMessage: string;
statusCode: number | null;
}
interface DomEditSaveQueueOptions {
failureThreshold?: number;
onOpen?: (event: DomEditSaveQueueOpenEvent) => void;
onReset?: () => void;
}
export interface DomEditSaveQueue {
enqueue: (save: () => Promise<void>) => Promise<void>;
waitForIdle: () => Promise<void>;
reset: () => void;
destroy: () => void;
}
const DEFAULT_FAILURE_THRESHOLD = 5;
export class DomEditSaveQueueOpenError extends Error {
constructor() {
super("Auto-save is paused. Dismiss the warning to retry DOM edits.");
this.name = "DomEditSaveQueueOpenError";
}
}
export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): DomEditSaveQueue {
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
let tail = Promise.resolve();
let consecutiveFailures = 0;
let breakerOpen = false;
const reset = (notify = true) => {
const wasOpen = breakerOpen;
consecutiveFailures = 0;
breakerOpen = false;
if (notify && wasOpen) options.onReset?.();
};
const open = (error: unknown) => {
if (breakerOpen) return;
breakerOpen = true;
options.onOpen?.({
consecutiveFailures,
errorMessage: getStudioSaveErrorMessage(error),
statusCode: getStudioSaveStatusCode(error) ?? null,
});
};
const run = async (save: () => Promise<void>) => {
try {
await save();
if (!breakerOpen) consecutiveFailures = 0;
} catch (error) {
consecutiveFailures += 1;
if (consecutiveFailures >= failureThreshold) open(error);
throw error;
}
};
return {
enqueue(save) {
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
const queued = tail.catch(() => undefined).then(() => run(save));
tail = queued.then(
() => undefined,
() => undefined,
);
return queued;
},
async waitForIdle() {
await tail.catch(() => undefined);
},
reset,
destroy() {
reset(false);
},
};
}