Skip to content

Commit fe9a6a8

Browse files
committed
refactor(studio): gate SDK operation families
1 parent b79a434 commit fe9a6a8

7 files changed

Lines changed: 290 additions & 39 deletions

File tree

packages/studio/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
"build": "vite build && tsup",
4141
"typecheck": "tsc --noEmit",
4242
"test": "vitest run",
43-
"test:watch": "vitest"
43+
"test:watch": "vitest",
44+
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
4445
},
4546
"dependencies": {
4647
"@codemirror/autocomplete": "^6.20.1",

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
8787
false,
8888
);
8989

90+
/** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
91+
export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
92+
env,
93+
STUDIO_SDK_CUTOVER_ENABLED,
94+
);
95+
9096
// Resolver-parity tripwire (telemetry-only, decoupled from cutover).
9197
// Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
9298
// divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
@@ -98,3 +104,4 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
98104
);
99105

100106
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
107+
import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../components/editor/manualEditingAvailability", () => ({
4+
STUDIO_SDK_CUTOVER_ENABLED: true,
5+
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
6+
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
7+
}));
8+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
9+
10+
import {
11+
sdkCutoverPersist,
12+
sdkDeletePersist,
13+
sdkGsapTweenPersist,
14+
sdkTimingPersist,
15+
} from "./sdkCutover";
16+
17+
const deps = {
18+
editHistory: { recordEdit: vi.fn() },
19+
writeProjectFile: vi.fn(),
20+
reloadPreview: vi.fn(),
21+
domEditSaveTimestampRef: { current: 0 },
22+
} as never;
23+
24+
describe("SDK family gate mapping", () => {
25+
it("enables timing independently while declining other operation families", async () => {
26+
await expect(
27+
sdkTimingPersist("hf", "/c.html", { start: 1 }, null, deps),
28+
).resolves.toMatchObject({ status: "declined", reason: "session_unavailable" });
29+
await expect(
30+
sdkGsapTweenPersist("/c.html", { kind: "remove", animationId: "a" }, null, deps),
31+
).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
32+
await expect(sdkDeletePersist("hf", "before", "/c.html", null, deps)).resolves.toMatchObject({
33+
status: "declined",
34+
reason: "feature_disabled",
35+
});
36+
await expect(
37+
sdkCutoverPersist(
38+
{ hfId: "hf" } as never,
39+
[{ type: "inline-style", property: "color", value: "red" }],
40+
"before",
41+
"/c.html",
42+
{} as never,
43+
deps,
44+
),
45+
).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
46+
});
47+
});

packages/studio/src/utils/sdkCutover.ts

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
22
import type { DomEditSelection } from "../components/editor/domEditing";
33
import type { PatchOperation } from "./sourcePatcher";
4-
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
4+
import * as studioAvailability from "../components/editor/manualEditingAvailability";
55
import { trackStudioEvent } from "./studioTelemetry";
66
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
77
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
@@ -15,6 +15,7 @@ import {
1515
type CutoverOptions,
1616
type CutoverResult,
1717
} from "./sdkEditTransaction";
18+
import { isSdkFamilyEnabled, type StudioSdkOperationFamily } from "./sdkCutoverPolicy";
1819

1920
export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
2021
export {
@@ -24,6 +25,27 @@ export {
2425
} from "./sdkEditTransaction";
2526
export type { CutoverDeps, CutoverOptions, CutoverResult } from "./sdkEditTransaction";
2627

28+
function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
29+
const configured = Object.prototype.hasOwnProperty.call(
30+
studioAvailability,
31+
"STUDIO_SDK_CUTOVER_FAMILIES",
32+
)
33+
? studioAvailability.STUDIO_SDK_CUTOVER_FAMILIES
34+
: undefined;
35+
return isSdkFamilyEnabled(studioAvailability.STUDIO_SDK_CUTOVER_ENABLED, configured, family);
36+
}
37+
38+
function trackCutoverResult(
39+
result: CutoverResult,
40+
context: { hfId?: string | null; opCount: number },
41+
): void {
42+
if (result.status === "committed") {
43+
trackStudioEvent("sdk_cutover_success", context);
44+
} else if (result.status === "failed") {
45+
trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message });
46+
}
47+
}
48+
2749
/** True when targetPath isn't the composition the SDK session models. */
2850
function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
2951
return deps.compositionPath != null && targetPath !== deps.compositionPath;
@@ -38,7 +60,7 @@ export async function sdkCutoverPersist(
3860
deps: CutoverDeps,
3961
options?: CutoverOptions,
4062
): Promise<CutoverResult> {
41-
if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
63+
if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
4264
return declinedCutover("ineligible_operation");
4365
if (!sdkSession) return declinedCutover("session_unavailable");
4466
const hfId = selection.hfId;
@@ -58,11 +80,7 @@ export async function sdkCutoverPersist(
5880
},
5981
options,
6082
);
61-
if (result.status === "committed") {
62-
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
63-
} else if (result.status === "failed") {
64-
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
65-
}
83+
trackCutoverResult(result, { hfId, opCount: ops.length });
6684
return result;
6785
}
6886

@@ -86,7 +104,7 @@ export async function sdkTimingPersist(
86104
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
87105
// exists (it always does, for shadow/selection) — flipping the flag OFF would
88106
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
89-
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
107+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
90108
if (!sdkSession) return declinedCutover("session_unavailable");
91109
if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
92110
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
@@ -102,11 +120,7 @@ export async function sdkTimingPersist(
102120
options,
103121
serializedBefore,
104122
);
105-
if (result.status === "committed") {
106-
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
107-
} else if (result.status === "failed") {
108-
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
109-
}
123+
trackCutoverResult(result, { hfId, opCount: 1 });
110124
return result;
111125
} catch (error) {
112126
const failed = { status: "failed", error: asCutoverError(error) } as const;
@@ -134,7 +148,7 @@ export async function sdkTimingBatchPersist(
134148
timingSrc ? () => timingSrc(targetPath) : undefined,
135149
);
136150
}
137-
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
151+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
138152
if (!sdkSession) return declinedCutover("session_unavailable");
139153
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
140154
if (changes.some((change) => !sdkSession.getElement(change.hfId)))
@@ -209,13 +223,14 @@ export function sdkGsapTweenPersist(
209223
}
210224
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
211225
// matches the other three chokepoints' discipline.
212-
if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(declinedCutover("feature_disabled"));
226+
if (!sdkFamilyEnabled("gsap-animation"))
227+
return Promise.resolve(declinedCutover("feature_disabled"));
213228
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
214229
return Promise.resolve(declinedCutover("target_not_found"));
215230
// dispatchGsapOpAndPersist declines on before===after — that catches stale
216231
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
217232
// back to the server path. This subsumes explicit existence guards for set/remove.
218-
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
233+
return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) => {
219234
s.batch(() => {
220235
if (op.kind === "add") {
221236
s.addGsapTween(op.target, op.spec);
@@ -229,6 +244,7 @@ export function sdkGsapTweenPersist(
229244
}
230245

231246
async function dispatchGsapOpAndPersist(
247+
family: "gsap-animation" | "gsap-keyframe",
232248
targetPath: string,
233249
sdkSession: Composition | null | undefined,
234250
deps: CutoverDeps,
@@ -243,7 +259,7 @@ async function dispatchGsapOpAndPersist(
243259
}
244260
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
245261
// flag OFF → explicit decline → caller falls back to the legacy server path.
246-
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
262+
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
247263
if (!sdkSession) return declinedCutover("session_unavailable");
248264
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
249265
const session = sdkSession;
@@ -291,6 +307,7 @@ export function sdkGsapKeyframePersist(
291307
options?: CutoverOptions,
292308
): Promise<CutoverResult> {
293309
return dispatchGsapOpAndPersist(
310+
"gsap-keyframe",
294311
targetPath,
295312
sdkSession,
296313
deps,
@@ -309,6 +326,7 @@ export function sdkGsapRemoveKeyframePersist(
309326
options?: CutoverOptions,
310327
): Promise<CutoverResult> {
311328
return dispatchGsapOpAndPersist(
329+
"gsap-keyframe",
312330
targetPath,
313331
sdkSession,
314332
deps,
@@ -328,6 +346,7 @@ export function sdkGsapRemovePropertyPersist(
328346
options?: CutoverOptions,
329347
): Promise<CutoverResult> {
330348
return dispatchGsapOpAndPersist(
349+
"gsap-animation",
331350
targetPath,
332351
sdkSession,
333352
deps,
@@ -344,7 +363,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
344363
deps: CutoverDeps,
345364
options?: CutoverOptions,
346365
): Promise<CutoverResult> {
347-
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
366+
return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) =>
348367
s.dispatch({ type: "deleteAllForSelector", selector }),
349368
);
350369
}
@@ -357,6 +376,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
357376
options?: CutoverOptions,
358377
): Promise<CutoverResult> {
359378
return dispatchGsapOpAndPersist(
379+
"gsap-keyframe",
360380
targetPath,
361381
sdkSession,
362382
deps,
@@ -375,6 +395,7 @@ export function sdkGsapConvertToKeyframesPersist(
375395
options?: CutoverOptions,
376396
): Promise<CutoverResult> {
377397
return dispatchGsapOpAndPersist(
398+
"gsap-keyframe",
378399
targetPath,
379400
sdkSession,
380401
deps,
@@ -399,6 +420,16 @@ type KeyframesPayload = {
399420
ease?: string;
400421
};
401422

423+
function keyframesPayload(
424+
targetSelector: string,
425+
position: number,
426+
duration: number,
427+
keyframes: KeyframeSpec[],
428+
ease: string | undefined,
429+
): KeyframesPayload {
430+
return { targetSelector, position, duration, keyframes, ...(ease ? { ease } : {}) };
431+
}
432+
402433
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
403434
function dispatchWithKeyframes(
404435
s: Composition,
@@ -412,6 +443,38 @@ function dispatchWithKeyframes(
412443
}
413444
}
414445

446+
function persistKeyframesOperation(input: {
447+
targetPath: string;
448+
targetSelector: string;
449+
position: number;
450+
duration: number;
451+
keyframes: KeyframeSpec[];
452+
ease: string | undefined;
453+
sdkSession: Composition | null | undefined;
454+
deps: CutoverDeps;
455+
options?: CutoverOptions;
456+
animationId?: string;
457+
}): Promise<CutoverResult> {
458+
const payload = keyframesPayload(
459+
input.targetSelector,
460+
input.position,
461+
input.duration,
462+
input.keyframes,
463+
input.ease,
464+
);
465+
return dispatchGsapOpAndPersist(
466+
"gsap-keyframe",
467+
input.targetPath,
468+
input.sdkSession,
469+
input.deps,
470+
input.options,
471+
(session) => dispatchWithKeyframes(session, payload, input.animationId),
472+
input.animationId
473+
? { animationId: input.animationId, opLabel: "replaceWithKeyframes" }
474+
: undefined,
475+
);
476+
}
477+
415478
export function sdkAddWithKeyframesPersist(
416479
targetPath: string,
417480
targetSelector: string,
@@ -423,16 +486,17 @@ export function sdkAddWithKeyframesPersist(
423486
deps: CutoverDeps,
424487
options?: CutoverOptions,
425488
): Promise<CutoverResult> {
426-
const payload: KeyframesPayload = {
489+
return persistKeyframesOperation({
490+
targetPath,
427491
targetSelector,
428492
position,
429493
duration,
430494
keyframes,
431-
...(ease ? { ease } : {}),
432-
};
433-
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
434-
dispatchWithKeyframes(s, payload),
435-
);
495+
ease,
496+
sdkSession,
497+
deps,
498+
options,
499+
});
436500
}
437501

438502
export function sdkReplaceWithKeyframesPersist(
@@ -447,21 +511,18 @@ export function sdkReplaceWithKeyframesPersist(
447511
deps: CutoverDeps,
448512
options?: CutoverOptions,
449513
): Promise<CutoverResult> {
450-
const payload: KeyframesPayload = {
514+
return persistKeyframesOperation({
515+
targetPath,
516+
animationId,
451517
targetSelector,
452518
position,
453519
duration,
454520
keyframes,
455-
...(ease ? { ease } : {}),
456-
};
457-
return dispatchGsapOpAndPersist(
458-
targetPath,
521+
ease,
459522
sdkSession,
460523
deps,
461524
options,
462-
(s) => dispatchWithKeyframes(s, payload, animationId),
463-
{ animationId, opLabel: "replaceWithKeyframes" },
464-
);
525+
});
465526
}
466527

467528
export async function sdkDeletePersist(
@@ -476,7 +537,7 @@ export async function sdkDeletePersist(
476537
Promise.resolve(originalContent),
477538
);
478539
// Dark-launch gate: flag OFF → legacy server delete path.
479-
if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
540+
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
480541
if (!sdkSession) return declinedCutover("session_unavailable");
481542
if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
482543
if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
@@ -488,10 +549,6 @@ export async function sdkDeletePersist(
488549
(session) => session.removeElement(hfId),
489550
{ label: "Delete element" },
490551
);
491-
if (result.status === "committed") {
492-
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
493-
} else if (result.status === "failed") {
494-
trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
495-
}
552+
trackCutoverResult(result, { hfId, opCount: 1 });
496553
return result;
497554
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { renderStudioSdkCutoverReport } from "./sdkCutoverPolicy";
2+
3+
process.stdout.write(renderStudioSdkCutoverReport());

0 commit comments

Comments
 (0)