@hyperframes/studio 0.7.90 → 0.7.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createDomEditSaveQueue } from "./domEditSaveQueue";
|
|
3
|
-
import { StudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
3
|
+
import { StudioFileConflictError, StudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
4
4
|
|
|
5
5
|
describe("dom edit save queue", () => {
|
|
6
6
|
afterEach(() => {
|
|
@@ -115,6 +115,23 @@ describe("dom edit save queue", () => {
|
|
|
115
115
|
queue.destroy();
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
it("clears a stale drain failure after a successful save", async () => {
|
|
119
|
+
const failure = new Error("temporary failure");
|
|
120
|
+
const queue = createDomEditSaveQueue();
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
queue.enqueue(async () => {
|
|
124
|
+
throw failure;
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toBe(failure);
|
|
127
|
+
await expect(queue.waitForIdle()).resolves.toEqual({ status: "failed", error: failure });
|
|
128
|
+
|
|
129
|
+
await queue.enqueue(async () => undefined);
|
|
130
|
+
|
|
131
|
+
await expect(queue.waitForIdle()).resolves.toEqual({ status: "clean" });
|
|
132
|
+
queue.destroy();
|
|
133
|
+
});
|
|
134
|
+
|
|
118
135
|
it("pauses immediately on a file conflict instead of retrying stale work", async () => {
|
|
119
136
|
const onOpen = vi.fn();
|
|
120
137
|
const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
|
|
@@ -133,4 +150,23 @@ describe("dom edit save queue", () => {
|
|
|
133
150
|
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
|
134
151
|
queue.destroy();
|
|
135
152
|
});
|
|
153
|
+
|
|
154
|
+
it("returns the original conflict from a drain instead of erasing it", async () => {
|
|
155
|
+
const conflict = new StudioFileConflictError({
|
|
156
|
+
filePath: "index.html",
|
|
157
|
+
currentVersion: "external-v2",
|
|
158
|
+
currentContent: "external",
|
|
159
|
+
attemptedContent: "studio",
|
|
160
|
+
});
|
|
161
|
+
const queue = createDomEditSaveQueue();
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
queue.enqueue(async () => {
|
|
165
|
+
throw conflict;
|
|
166
|
+
}),
|
|
167
|
+
).rejects.toBe(conflict);
|
|
168
|
+
|
|
169
|
+
await expect(queue.waitForIdle()).resolves.toEqual({ status: "conflict", error: conflict });
|
|
170
|
+
queue.destroy();
|
|
171
|
+
});
|
|
136
172
|
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getStudioSaveErrorMessage,
|
|
3
|
+
getStudioSaveStatusCode,
|
|
4
|
+
StudioFileConflictError,
|
|
5
|
+
type StudioSaveDrainResult,
|
|
6
|
+
} from "./studioSaveDiagnostics";
|
|
2
7
|
|
|
3
8
|
interface DomEditSaveQueueOpenEvent {
|
|
4
9
|
consecutiveFailures: number;
|
|
@@ -14,11 +19,13 @@ interface DomEditSaveQueueOptions {
|
|
|
14
19
|
|
|
15
20
|
export interface DomEditSaveQueue {
|
|
16
21
|
enqueue: <T>(save: () => Promise<T>) => Promise<T>;
|
|
17
|
-
waitForIdle: () => Promise<
|
|
22
|
+
waitForIdle: () => Promise<DomEditSaveDrainResult>;
|
|
18
23
|
reset: () => void;
|
|
19
24
|
destroy: () => void;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
export type DomEditSaveDrainResult = StudioSaveDrainResult;
|
|
28
|
+
|
|
22
29
|
const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
23
30
|
|
|
24
31
|
export class DomEditSaveQueueOpenError extends Error {
|
|
@@ -34,11 +41,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
34
41
|
let tail = Promise.resolve();
|
|
35
42
|
let consecutiveFailures = 0;
|
|
36
43
|
let breakerOpen = false;
|
|
44
|
+
let drainError: unknown = null;
|
|
37
45
|
|
|
38
46
|
const reset = (notify = true) => {
|
|
39
47
|
const wasOpen = breakerOpen;
|
|
40
48
|
consecutiveFailures = 0;
|
|
41
49
|
breakerOpen = false;
|
|
50
|
+
drainError = null;
|
|
42
51
|
if (notify && wasOpen) options.onReset?.();
|
|
43
52
|
};
|
|
44
53
|
|
|
@@ -55,9 +64,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
55
64
|
const run = async <T>(save: () => Promise<T>): Promise<T> => {
|
|
56
65
|
try {
|
|
57
66
|
const result = await save();
|
|
58
|
-
if (!breakerOpen)
|
|
67
|
+
if (!breakerOpen) {
|
|
68
|
+
consecutiveFailures = 0;
|
|
69
|
+
drainError = null;
|
|
70
|
+
}
|
|
59
71
|
return result;
|
|
60
72
|
} catch (error) {
|
|
73
|
+
drainError = error;
|
|
61
74
|
consecutiveFailures += 1;
|
|
62
75
|
if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
|
|
63
76
|
open(error);
|
|
@@ -76,8 +89,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
76
89
|
return queued;
|
|
77
90
|
},
|
|
78
91
|
|
|
79
|
-
async waitForIdle() {
|
|
92
|
+
async waitForIdle(): Promise<DomEditSaveDrainResult> {
|
|
80
93
|
await tail.catch(() => undefined);
|
|
94
|
+
if (drainError instanceof StudioFileConflictError) {
|
|
95
|
+
return { status: "conflict", error: drainError };
|
|
96
|
+
}
|
|
97
|
+
if (drainError != null) return { status: "failed", error: drainError };
|
|
98
|
+
return { status: "clean" };
|
|
81
99
|
},
|
|
82
100
|
|
|
83
101
|
reset,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createMemoryExternalConflictStorage,
|
|
5
|
+
deleteExternalConflictSnapshot,
|
|
6
|
+
loadExternalConflictSnapshot,
|
|
7
|
+
persistExternalConflictSnapshot,
|
|
8
|
+
type ExternalConflictSnapshot,
|
|
9
|
+
} from "./externalConflictStorage";
|
|
10
|
+
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
11
|
+
|
|
12
|
+
describe("external conflict snapshots", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.stubGlobal("indexedDB", new IDBFactory());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("durably records both complete file versions before resolution", async () => {
|
|
23
|
+
const storage = createMemoryExternalConflictStorage();
|
|
24
|
+
const snapshot: ExternalConflictSnapshot = {
|
|
25
|
+
kind: "conflict",
|
|
26
|
+
projectId: "project-a",
|
|
27
|
+
filePath: "index.html",
|
|
28
|
+
externalVersion: "v2",
|
|
29
|
+
externalContent: "external full file",
|
|
30
|
+
studioContent: "studio full file",
|
|
31
|
+
createdAt: 100,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await storage.set(snapshot);
|
|
35
|
+
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
|
|
36
|
+
await storage.delete("project-a", "index.html");
|
|
37
|
+
expect(await storage.get("project-a", "index.html")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("records a failed final Studio candidate for remount recovery", async () => {
|
|
41
|
+
const storage = createMemoryExternalConflictStorage();
|
|
42
|
+
const snapshot: ExternalConflictSnapshot = {
|
|
43
|
+
kind: "failed",
|
|
44
|
+
projectId: "project-a",
|
|
45
|
+
filePath: "index.html",
|
|
46
|
+
externalVersion: null,
|
|
47
|
+
externalContent: null,
|
|
48
|
+
studioContent: "recover me",
|
|
49
|
+
failureMessage: "network unavailable",
|
|
50
|
+
createdAt: 101,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await storage.set(snapshot);
|
|
54
|
+
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("persists, loads, and deletes a snapshot through the production IndexedDB adapter", async () => {
|
|
58
|
+
vi.spyOn(Date, "now").mockReturnValue(1234);
|
|
59
|
+
const conflict = new StudioFileConflictError({
|
|
60
|
+
filePath: "index.html",
|
|
61
|
+
currentVersion: "v2",
|
|
62
|
+
currentContent: "external full file",
|
|
63
|
+
attemptedContent: "studio full file",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await persistExternalConflictSnapshot("indexeddb-project", conflict);
|
|
67
|
+
await expect(loadExternalConflictSnapshot("indexeddb-project", "index.html")).resolves.toEqual({
|
|
68
|
+
kind: "conflict",
|
|
69
|
+
projectId: "indexeddb-project",
|
|
70
|
+
filePath: "index.html",
|
|
71
|
+
externalVersion: "v2",
|
|
72
|
+
externalContent: "external full file",
|
|
73
|
+
studioContent: "studio full file",
|
|
74
|
+
createdAt: 1234,
|
|
75
|
+
});
|
|
76
|
+
await deleteExternalConflictSnapshot("indexeddb-project", "index.html");
|
|
77
|
+
await expect(
|
|
78
|
+
loadExternalConflictSnapshot("indexeddb-project", "index.html"),
|
|
79
|
+
).resolves.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects persistence when IndexedDB is unavailable", async () => {
|
|
83
|
+
vi.stubGlobal("indexedDB", undefined);
|
|
84
|
+
const conflict = new StudioFileConflictError({
|
|
85
|
+
filePath: "index.html",
|
|
86
|
+
currentVersion: "v2",
|
|
87
|
+
currentContent: "external",
|
|
88
|
+
attemptedContent: "studio",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await expect(persistExternalConflictSnapshot("project-a", conflict)).rejects.toThrow(
|
|
92
|
+
"IndexedDB is unavailable; conflict recovery snapshot was not saved",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
2
|
+
|
|
3
|
+
interface ExternalRecoverySnapshotBase {
|
|
4
|
+
projectId: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
externalVersion: string | null;
|
|
7
|
+
externalContent: string | null;
|
|
8
|
+
studioContent: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ExternalConflictSnapshot =
|
|
13
|
+
| (ExternalRecoverySnapshotBase & { kind: "conflict" })
|
|
14
|
+
| (ExternalRecoverySnapshotBase & { kind: "failed"; failureMessage: string });
|
|
15
|
+
|
|
16
|
+
export interface ExternalConflictStorage {
|
|
17
|
+
get(projectId: string, filePath: string): Promise<ExternalConflictSnapshot | null>;
|
|
18
|
+
set(snapshot: ExternalConflictSnapshot): Promise<void>;
|
|
19
|
+
delete(projectId: string, filePath: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DB_NAME = "hyperframes-studio-external-conflicts";
|
|
23
|
+
const DB_VERSION = 1;
|
|
24
|
+
const STORE_NAME = "file-conflicts";
|
|
25
|
+
|
|
26
|
+
function key(projectId: string, filePath: string): string {
|
|
27
|
+
return `${projectId}\0${filePath}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createMemoryExternalConflictStorage(): ExternalConflictStorage {
|
|
31
|
+
const snapshots = new Map<string, ExternalConflictSnapshot>();
|
|
32
|
+
return {
|
|
33
|
+
async get(projectId, filePath) {
|
|
34
|
+
const snapshot = snapshots.get(key(projectId, filePath));
|
|
35
|
+
return snapshot ? structuredClone(snapshot) : null;
|
|
36
|
+
},
|
|
37
|
+
async set(snapshot) {
|
|
38
|
+
snapshots.set(key(snapshot.projectId, snapshot.filePath), structuredClone(snapshot));
|
|
39
|
+
},
|
|
40
|
+
async delete(projectId, filePath) {
|
|
41
|
+
snapshots.delete(key(projectId, filePath));
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openDb(): Promise<IDBDatabase> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (!globalThis.indexedDB) {
|
|
49
|
+
reject(new Error("IndexedDB is unavailable; conflict recovery snapshot was not saved"));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
|
53
|
+
request.onupgradeneeded = () => {
|
|
54
|
+
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
55
|
+
request.result.createObjectStore(STORE_NAME);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open conflict storage"));
|
|
59
|
+
request.onsuccess = () => resolve(request.result);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function withStore<T>(
|
|
64
|
+
mode: IDBTransactionMode,
|
|
65
|
+
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
return openDb().then(
|
|
68
|
+
(db) =>
|
|
69
|
+
new Promise<T>((resolve, reject) => {
|
|
70
|
+
const transaction = db.transaction(STORE_NAME, mode);
|
|
71
|
+
const request = callback(transaction.objectStore(STORE_NAME));
|
|
72
|
+
let result!: T;
|
|
73
|
+
request.onerror = () => reject(request.error ?? new Error("Conflict storage failed"));
|
|
74
|
+
request.onsuccess = () => {
|
|
75
|
+
result = request.result;
|
|
76
|
+
};
|
|
77
|
+
transaction.oncomplete = () => {
|
|
78
|
+
db.close();
|
|
79
|
+
resolve(result);
|
|
80
|
+
};
|
|
81
|
+
transaction.onerror = () => {
|
|
82
|
+
db.close();
|
|
83
|
+
reject(transaction.error ?? new Error("Conflict storage transaction failed"));
|
|
84
|
+
};
|
|
85
|
+
transaction.onabort = () => {
|
|
86
|
+
db.close();
|
|
87
|
+
reject(transaction.error ?? new Error("Conflict storage transaction was aborted"));
|
|
88
|
+
};
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createIndexedDbExternalConflictStorage(): ExternalConflictStorage {
|
|
94
|
+
return {
|
|
95
|
+
async get(projectId, filePath) {
|
|
96
|
+
return (
|
|
97
|
+
(await withStore<ExternalConflictSnapshot | undefined>("readonly", (store) =>
|
|
98
|
+
store.get(key(projectId, filePath)),
|
|
99
|
+
)) ?? null
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
async set(snapshot) {
|
|
103
|
+
await withStore<IDBValidKey>("readwrite", (store) =>
|
|
104
|
+
store.put(snapshot, key(snapshot.projectId, snapshot.filePath)),
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
async delete(projectId, filePath) {
|
|
108
|
+
await withStore<undefined>("readwrite", (store) => store.delete(key(projectId, filePath)));
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const indexedDbStorage = createIndexedDbExternalConflictStorage();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Persist a conflict before offering destructive recovery actions.
|
|
117
|
+
* Callers must await this promise: rejection means the Studio draft was not saved durably and
|
|
118
|
+
* must remain available in memory while the storage failure is surfaced to the user.
|
|
119
|
+
*/
|
|
120
|
+
export async function persistExternalConflictSnapshot(
|
|
121
|
+
projectId: string,
|
|
122
|
+
conflict: StudioFileConflictError,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
await indexedDbStorage.set({
|
|
125
|
+
kind: "conflict",
|
|
126
|
+
projectId,
|
|
127
|
+
filePath: conflict.filePath,
|
|
128
|
+
externalVersion: conflict.currentVersion,
|
|
129
|
+
externalContent: conflict.currentContent,
|
|
130
|
+
studioContent: conflict.attemptedContent,
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Persist the final Studio candidate after its file write fails.
|
|
137
|
+
* Callers must await this promise: rejection means the Studio draft was not saved durably and
|
|
138
|
+
* must remain available in memory while the storage failure is surfaced to the user.
|
|
139
|
+
*/
|
|
140
|
+
export async function persistExternalFailureSnapshot(
|
|
141
|
+
projectId: string,
|
|
142
|
+
filePath: string,
|
|
143
|
+
studioContent: string,
|
|
144
|
+
externalVersion: string | null,
|
|
145
|
+
externalContent: string | null,
|
|
146
|
+
error: unknown,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
await indexedDbStorage.set({
|
|
149
|
+
kind: "failed",
|
|
150
|
+
projectId,
|
|
151
|
+
filePath,
|
|
152
|
+
externalVersion,
|
|
153
|
+
externalContent,
|
|
154
|
+
studioContent,
|
|
155
|
+
failureMessage: error instanceof Error ? error.message : String(error),
|
|
156
|
+
createdAt: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadExternalConflictSnapshot(
|
|
161
|
+
projectId: string,
|
|
162
|
+
filePath: string,
|
|
163
|
+
): Promise<ExternalConflictSnapshot | null> {
|
|
164
|
+
return indexedDbStorage.get(projectId, filePath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function deleteExternalConflictSnapshot(
|
|
168
|
+
projectId: string,
|
|
169
|
+
filePath: string,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
await indexedDbStorage.delete(projectId, filePath);
|
|
172
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
consumeStudioWriteToken,
|
|
4
|
+
markStudioWriteToken,
|
|
5
|
+
resetStudioWriteTokens,
|
|
6
|
+
studioExpectedFileVersion,
|
|
7
|
+
studioFileContentVersion,
|
|
8
|
+
} from "./studioFileVersion";
|
|
3
9
|
|
|
4
10
|
describe("studioFileContentVersion", () => {
|
|
5
11
|
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
|
|
@@ -34,3 +40,24 @@ describe("studioFileContentVersion", () => {
|
|
|
34
40
|
expect(await studioExpectedFileVersion(versions, "untracked.html")).toBeUndefined();
|
|
35
41
|
});
|
|
36
42
|
});
|
|
43
|
+
|
|
44
|
+
describe("studio write-token echo identity", () => {
|
|
45
|
+
it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
|
|
46
|
+
resetStudioWriteTokens();
|
|
47
|
+
markStudioWriteToken("studio-write-1");
|
|
48
|
+
|
|
49
|
+
expect(consumeStudioWriteToken("studio-write-1")).toBe(true);
|
|
50
|
+
expect(consumeStudioWriteToken("studio-write-1")).toBe(false);
|
|
51
|
+
expect(consumeStudioWriteToken(null)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps a token through a slow write and expires abandoned identity state", () => {
|
|
55
|
+
resetStudioWriteTokens();
|
|
56
|
+
markStudioWriteToken("slow-studio-write", 1_000);
|
|
57
|
+
|
|
58
|
+
expect(consumeStudioWriteToken("slow-studio-write", 61_000)).toBe(true);
|
|
59
|
+
|
|
60
|
+
markStudioWriteToken("abandoned-studio-write", 1_000);
|
|
61
|
+
expect(consumeStudioWriteToken("abandoned-studio-write", 301_000)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
// A token is marked before the request starts, so its lifetime must cover slow writes,
|
|
2
|
+
// retries, and the subsequent file-change echo without retaining abandoned tokens forever.
|
|
3
|
+
const WRITE_TOKEN_TTL_MS = 5 * 60_000;
|
|
4
|
+
const studioWriteTokens = new Map<string, number>();
|
|
5
|
+
|
|
6
|
+
function pruneStudioWriteTokens(now: number): void {
|
|
7
|
+
for (const [token, createdAt] of studioWriteTokens) {
|
|
8
|
+
if (now - createdAt >= WRITE_TOKEN_TTL_MS) studioWriteTokens.delete(token);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Marked by the Studio file writer introduced in external-change stack PR #2990. */
|
|
13
|
+
export function markStudioWriteToken(token: string, now: number = Date.now()): void {
|
|
14
|
+
pruneStudioWriteTokens(now);
|
|
15
|
+
studioWriteTokens.set(token, now);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Consumed by the external-change coordinator introduced in stack PR #2991. */
|
|
19
|
+
export function consumeStudioWriteToken(token: string | null, now: number = Date.now()): boolean {
|
|
20
|
+
pruneStudioWriteTokens(now);
|
|
21
|
+
if (!token || !studioWriteTokens.has(token)) return false;
|
|
22
|
+
studioWriteTokens.delete(token);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resets module-level echo identity between PR #2991 coordinator tests. */
|
|
27
|
+
export function resetStudioWriteTokens(): void {
|
|
28
|
+
studioWriteTokens.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
1
31
|
/** Browser-safe SHA-256 version matching studio-server's strong ETag format. */
|
|
2
32
|
export async function studioFileContentVersion(content: string): Promise<string> {
|
|
3
33
|
const bytes = new TextEncoder().encode(content);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
flushStudioPendingEdits,
|
|
6
6
|
trackStudioPendingEdit,
|
|
7
7
|
} from "./studioPendingEdits";
|
|
8
|
+
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
8
9
|
|
|
9
10
|
describe("studio pending edit flush", () => {
|
|
10
11
|
it("waits for mounted panels to persist pending local edits", async () => {
|
|
@@ -12,13 +13,119 @@ describe("studio pending edit flush", () => {
|
|
|
12
13
|
const remove = addStudioPendingEditFlushListener(persist);
|
|
13
14
|
|
|
14
15
|
try {
|
|
15
|
-
await flushStudioPendingEdits();
|
|
16
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
16
17
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
17
18
|
} finally {
|
|
18
19
|
remove();
|
|
19
20
|
}
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
it("commits the focused debounced field before draining pending work", async () => {
|
|
24
|
+
const input = document.createElement("textarea");
|
|
25
|
+
document.body.append(input);
|
|
26
|
+
const persist = vi.fn(async () => undefined);
|
|
27
|
+
input.addEventListener("blur", () => {
|
|
28
|
+
trackStudioPendingEdit(persist());
|
|
29
|
+
});
|
|
30
|
+
input.focus();
|
|
31
|
+
|
|
32
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
33
|
+
|
|
34
|
+
expect(document.activeElement).not.toBe(input);
|
|
35
|
+
expect(persist).toHaveBeenCalledOnce();
|
|
36
|
+
input.remove();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("waits for a post-blur effect to register its pending edit listener", async () => {
|
|
40
|
+
const input = document.createElement("textarea");
|
|
41
|
+
document.body.append(input);
|
|
42
|
+
const persist = vi.fn(async () => undefined);
|
|
43
|
+
let removeListener: (() => void) | undefined;
|
|
44
|
+
let registrationDone: Promise<void> | undefined;
|
|
45
|
+
input.addEventListener("blur", () => {
|
|
46
|
+
registrationDone = new Promise<void>((resolve) => {
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
removeListener = addStudioPendingEditFlushListener(persist);
|
|
49
|
+
resolve();
|
|
50
|
+
}, 0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
input.focus();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
57
|
+
|
|
58
|
+
expect(persist).toHaveBeenCalledOnce();
|
|
59
|
+
} finally {
|
|
60
|
+
await registrationDone;
|
|
61
|
+
removeListener?.();
|
|
62
|
+
input.remove();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("preserves a pending edit failure instead of reporting a clean drain", async () => {
|
|
67
|
+
const failure = new Error("field save failed");
|
|
68
|
+
const remove = addStudioPendingEditFlushListener(async () => {
|
|
69
|
+
throw failure;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({
|
|
74
|
+
status: "failed",
|
|
75
|
+
error: failure,
|
|
76
|
+
});
|
|
77
|
+
} finally {
|
|
78
|
+
remove();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the full typed conflict payload for the external-change decision", async () => {
|
|
83
|
+
const conflict = new StudioFileConflictError({
|
|
84
|
+
filePath: "index.html",
|
|
85
|
+
currentVersion: "v2",
|
|
86
|
+
currentContent: "external",
|
|
87
|
+
attemptedContent: "studio",
|
|
88
|
+
});
|
|
89
|
+
const remove = addStudioPendingEditFlushListener(async () => {
|
|
90
|
+
throw conflict;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({
|
|
95
|
+
status: "conflict",
|
|
96
|
+
error: conflict,
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
remove();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("prioritizes a conflict when pending edits fail with mixed errors", async () => {
|
|
104
|
+
const failure = new Error("field save failed");
|
|
105
|
+
const conflict = new StudioFileConflictError({
|
|
106
|
+
filePath: "index.html",
|
|
107
|
+
currentVersion: "v2",
|
|
108
|
+
currentContent: "external",
|
|
109
|
+
attemptedContent: "studio",
|
|
110
|
+
});
|
|
111
|
+
const removeFailure = addStudioPendingEditFlushListener(async () => {
|
|
112
|
+
throw failure;
|
|
113
|
+
});
|
|
114
|
+
const removeConflict = addStudioPendingEditFlushListener(async () => {
|
|
115
|
+
throw conflict;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({
|
|
120
|
+
status: "conflict",
|
|
121
|
+
error: conflict,
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
removeFailure();
|
|
125
|
+
removeConflict();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
22
129
|
it("waits for edits already started by unmounted panels", async () => {
|
|
23
130
|
const steps: string[] = [];
|
|
24
131
|
let resolvePersist!: () => void;
|
|
@@ -1,11 +1,32 @@
|
|
|
1
|
+
import { StudioFileConflictError, type StudioSaveDrainResult } from "./studioSaveDiagnostics";
|
|
2
|
+
|
|
1
3
|
const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
|
|
2
4
|
|
|
3
5
|
interface StudioFlushPendingEditsDetail {
|
|
4
6
|
promises: Array<Promise<unknown>>;
|
|
5
7
|
}
|
|
6
8
|
|
|
9
|
+
export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
|
|
10
|
+
|
|
7
11
|
const pendingEditPromises = new Set<Promise<unknown>>();
|
|
8
12
|
|
|
13
|
+
function waitForPostBlurEffects(): Promise<void> {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function inspectDrainFailures(results: PromiseSettledResult<unknown>[]): {
|
|
18
|
+
conflict?: StudioFileConflictError;
|
|
19
|
+
firstFailure?: PromiseRejectedResult;
|
|
20
|
+
} {
|
|
21
|
+
let firstFailure: PromiseRejectedResult | undefined;
|
|
22
|
+
for (const result of results) {
|
|
23
|
+
if (result.status !== "rejected") continue;
|
|
24
|
+
if (result.reason instanceof StudioFileConflictError) return { conflict: result.reason };
|
|
25
|
+
firstFailure ??= result;
|
|
26
|
+
}
|
|
27
|
+
return { firstFailure };
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
export function trackStudioPendingEdit(
|
|
10
31
|
result: Promise<unknown> | unknown,
|
|
11
32
|
): Promise<unknown> | undefined {
|
|
@@ -19,16 +40,32 @@ export function trackStudioPendingEdit(
|
|
|
19
40
|
return promise;
|
|
20
41
|
}
|
|
21
42
|
|
|
22
|
-
export async function flushStudioPendingEdits(): Promise<
|
|
43
|
+
export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
|
|
44
|
+
const active = document.activeElement;
|
|
45
|
+
if (
|
|
46
|
+
active instanceof HTMLElement &&
|
|
47
|
+
active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
|
|
48
|
+
) {
|
|
49
|
+
active.blur();
|
|
50
|
+
// ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary
|
|
51
|
+
// so React effects triggered by the blur can register their flush listener.
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
await waitForPostBlurEffects();
|
|
54
|
+
}
|
|
23
55
|
const detail: StudioFlushPendingEditsDetail = { promises: [] };
|
|
24
56
|
window.dispatchEvent(
|
|
25
57
|
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
|
|
26
58
|
);
|
|
59
|
+
let firstFailure: PromiseRejectedResult | undefined;
|
|
27
60
|
while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
|
|
28
61
|
const promises = [...detail.promises, ...pendingEditPromises];
|
|
29
62
|
detail.promises = [];
|
|
30
|
-
await Promise.allSettled(promises);
|
|
63
|
+
const results = await Promise.allSettled(promises);
|
|
64
|
+
const batchFailures = inspectDrainFailures(results);
|
|
65
|
+
if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict };
|
|
66
|
+
firstFailure ??= batchFailures.firstFailure;
|
|
31
67
|
}
|
|
68
|
+
return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
export function addStudioPendingEditFlushListener(
|
|
@@ -56,6 +56,11 @@ export class StudioFileConflictError extends StudioSaveHttpError {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export type StudioSaveDrainResult<Failure = unknown> =
|
|
60
|
+
| { status: "clean" }
|
|
61
|
+
| { status: "conflict"; error: StudioFileConflictError }
|
|
62
|
+
| { status: "failed"; error: Failure };
|
|
63
|
+
|
|
59
64
|
function readNumericProperty(value: object, key: string): number | undefined {
|
|
60
65
|
const record = value as Record<string, unknown>;
|
|
61
66
|
const property = record[key];
|