Files
hyperframes/packages/studio/src/utils/sdkShadow.ts
T
69aa595f38 feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode (#1450)
* feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode

Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession,
calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to
dispatch equivalent SDK ops alongside the server patch path and emit
sdk_shadow_dispatch telemetry with mismatch details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch

Wrap the dispatch loop in try/catch so a throwing SDK dispatch never
propagates to Studio UX. Returns dispatched:false with kind="dispatch_error"
and the error message for telemetry. One new TDD test (RED→GREEN verified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import

Wrap the shadow dispatch loop in session.batch() so a mid-loop throw
cannot leave the SDK session in a partially-applied state. Without the
batch boundary, one failing op would update some elements but not
others, diverging the shadow session from the real one.

Rename reportShadowDispatch → runShadowDispatch to eliminate the
misleading 'report' prefix — the function mutates the SDK session, it
is not read-only. Update the only caller (useDomEditSession).

Add missing PatchOperation import to useDomEditCommits (the type was
already used in the onDomEditPersisted interface but never imported).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* docs(studio/sdkShadow): note persist:error drift risk in parity comparisons

Also remove unused re-exports from useDomEditCommits (GSAP_CSS_FALLBACK_BLOCKED_MESSAGE
and PersistDomEditOperations — fallow confirmed 0 consumers) and suppress the
Vite ?raw import in sdk-playground that fallow can't resolve statically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-15 15:38:35 -07:00

207 lines
7.1 KiB
TypeScript

/**
* SDK shadow dispatch utilities for Stage 7 Step 3b.
*
* Shadow mode keeps the server patch path authoritative while also dispatching
* the equivalent op to the SDK session, then compares the result to detect
* addressing gaps (blocker E: no-hf-id elements) and serialization drift
* (blocker B: linkedom whole-doc serialize). Results are reported as structured
* mismatches for telemetry — no user-visible change.
*/
import type { Composition } from "@hyperframes/sdk";
import type { EditOp } from "@hyperframes/sdk";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { PatchOperation } from "./sourcePatcher";
// ─── Op mapping ──────────────────────────────────────────────────────────────
/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;
for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: `data-${op.property}`,
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
// unknown op types produce no SDK op
}
if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}
return result;
}
// ─── Shadow result types ──────────────────────────────────────────────────────
export interface SdkShadowMismatch {
kind: "element_not_found" | "value_mismatch" | "dispatch_error";
hfId: string;
property?: string;
expected?: string | null;
actual?: string | null | undefined;
error?: string;
}
export interface SdkShadowResult {
/** False if the element was not found in the SDK session. */
dispatched: boolean;
mismatches: SdkShadowMismatch[];
}
// ─── Shadow dispatch ──────────────────────────────────────────────────────────
type ElementSnapshot = ReturnType<Composition["getElement"]>;
type OpFields = {
property: string;
expected: string | null | undefined;
actual: string | null | undefined;
};
type FlatSnapshot = {
styles: Record<string, string | null>;
attrs: Record<string, string | null>;
text: string | null;
};
function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
return {
styles: snap?.inlineStyles ?? {},
attrs: Object.fromEntries(
Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
),
text: snap?.text ?? null,
};
}
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
"inline-style": (op, flat) => ({
property: op.property,
expected: op.value,
actual: flat.styles[op.property] ?? null,
}),
"text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
attribute: (op, flat) => ({
property: `data-${op.property}`,
expected: op.value ?? null,
actual: flat.attrs[`data-${op.property}`] ?? null,
}),
"html-attribute": (op, flat) => ({
property: op.property,
expected: op.value ?? null,
actual: flat.attrs[op.property] ?? null,
}),
};
function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
}
function checkOpParity(
op: PatchOperation,
flat: FlatSnapshot,
hfId: string,
): SdkShadowMismatch | null {
const fields = resolveOpFields(op, flat);
if (!fields || fields.actual === fields.expected) return null;
return { kind: "value_mismatch", hfId, ...fields };
}
/**
* Dispatch PatchOperations to the SDK session and return a parity report.
*
* If the element is not found by hfId, returns dispatched:false with a
* element_not_found mismatch (signals blocker E — element has no hf-id or
* SDK can't address it).
*
* On success, verifies that the SDK element snapshot reflects the applied
* values. Value mismatches indicate serialization or normalization drift.
*
* **persist:error drift risk**: the HTTP adapter fires persist:error on
* network failure but the SDK session is already mutated at that point. If
* the server file was not updated (e.g. 503), subsequent shadow parity
* comparisons here will see a diverged SDK session and produce false
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
* window is clear of persist:error events.
*/
export function sdkShadowDispatch(
session: Composition,
hfId: string,
ops: PatchOperation[],
): SdkShadowResult {
if (!session.getElement(hfId)) {
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
}
try {
const sdkOps = patchOpsToSdkEditOps(hfId, ops);
session.batch(() => {
for (const op of sdkOps) session.dispatch(op);
});
} catch (err) {
return {
dispatched: false,
mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
};
}
const flat = flattenSnapshot(session.getElement(hfId));
const mismatches = ops
.map((op) => checkOpParity(op, flat, hfId))
.filter((m): m is SdkShadowMismatch => m !== null);
return { dispatched: true, mismatches };
}
// ─── Telemetry reporting ──────────────────────────────────────────────────────
/**
* Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
* Despite the telemetry focus, this function does mutate the SDK session — it
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
*/
export function runShadowDispatch(
session: Composition,
selection: DomEditSelection,
ops: PatchOperation[],
): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
const hfId = selection.hfId;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
const result = sdkShadowDispatch(session, hfId, ops);
trackStudioEvent("sdk_shadow_dispatch", {
dispatched: result.dispatched,
mismatchCount: result.mismatches.length,
mismatches: JSON.stringify(result.mismatches),
});
}