@hyperframes/studio 0.6.98 → 0.6.100

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SDK shadow dispatch utilities for Stage 7 Step 3b.
3
+ *
4
+ * Shadow mode keeps the server patch path authoritative while also dispatching
5
+ * the equivalent op to the SDK session, then compares the result to detect
6
+ * addressing gaps (blocker E: no-hf-id elements) and serialization drift
7
+ * (blocker B: linkedom whole-doc serialize). Results are reported as structured
8
+ * mismatches for telemetry — no user-visible change.
9
+ */
10
+
11
+ import type { Composition } from "@hyperframes/sdk";
12
+ import type { EditOp } from "@hyperframes/sdk";
13
+ import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
14
+ import { trackStudioEvent } from "./studioTelemetry";
15
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
16
+ import type { PatchOperation } from "./sourcePatcher";
17
+
18
+ // ─── Op mapping ──────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Map Studio PatchOperations for a given hf-id to SDK EditOps.
22
+ *
23
+ * Multiple inline-style ops are coalesced into a single setStyle (SDK batches
24
+ * style changes naturally). One SDK op is emitted per non-style op.
25
+ */
26
+ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
27
+ const result: EditOp[] = [];
28
+ const styles: Record<string, string | null> = {};
29
+ let hasStyles = false;
30
+
31
+ for (const op of ops) {
32
+ if (op.type === "inline-style") {
33
+ styles[op.property] = op.value;
34
+ hasStyles = true;
35
+ } else if (op.type === "text-content") {
36
+ result.push({ type: "setText", target: hfId, value: op.value ?? "" });
37
+ } else if (op.type === "attribute") {
38
+ result.push({
39
+ type: "setAttribute",
40
+ target: hfId,
41
+ name: `data-${op.property}`,
42
+ value: op.value,
43
+ });
44
+ } else if (op.type === "html-attribute") {
45
+ result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
46
+ }
47
+ // unknown op types produce no SDK op
48
+ }
49
+
50
+ if (hasStyles) {
51
+ result.unshift({ type: "setStyle", target: hfId, styles });
52
+ }
53
+
54
+ return result;
55
+ }
56
+
57
+ // ─── Shadow result types ──────────────────────────────────────────────────────
58
+
59
+ export interface SdkShadowMismatch {
60
+ kind: "element_not_found" | "value_mismatch" | "dispatch_error";
61
+ hfId: string;
62
+ property?: string;
63
+ expected?: string | null;
64
+ actual?: string | null | undefined;
65
+ error?: string;
66
+ }
67
+
68
+ export interface SdkShadowResult {
69
+ /** False if the element was not found in the SDK session. */
70
+ dispatched: boolean;
71
+ mismatches: SdkShadowMismatch[];
72
+ }
73
+
74
+ // ─── Shadow dispatch ──────────────────────────────────────────────────────────
75
+
76
+ type ElementSnapshot = ReturnType<Composition["getElement"]>;
77
+ type OpFields = {
78
+ property: string;
79
+ expected: string | null | undefined;
80
+ actual: string | null | undefined;
81
+ };
82
+
83
+ type FlatSnapshot = {
84
+ styles: Record<string, string | null>;
85
+ attrs: Record<string, string | null>;
86
+ text: string | null;
87
+ };
88
+
89
+ function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
90
+ return {
91
+ styles: snap?.inlineStyles ?? {},
92
+ attrs: Object.fromEntries(
93
+ Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
94
+ ),
95
+ text: snap?.text ?? null,
96
+ };
97
+ }
98
+
99
+ type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
100
+
101
+ const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
102
+ "inline-style": (op, flat) => ({
103
+ property: op.property,
104
+ expected: op.value,
105
+ actual: flat.styles[op.property] ?? null,
106
+ }),
107
+ "text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
108
+ attribute: (op, flat) => ({
109
+ property: `data-${op.property}`,
110
+ expected: op.value ?? null,
111
+ actual: flat.attrs[`data-${op.property}`] ?? null,
112
+ }),
113
+ "html-attribute": (op, flat) => ({
114
+ property: op.property,
115
+ expected: op.value ?? null,
116
+ actual: flat.attrs[op.property] ?? null,
117
+ }),
118
+ };
119
+
120
+ function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
121
+ return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
122
+ }
123
+
124
+ function checkOpParity(
125
+ op: PatchOperation,
126
+ flat: FlatSnapshot,
127
+ hfId: string,
128
+ ): SdkShadowMismatch | null {
129
+ const fields = resolveOpFields(op, flat);
130
+ if (!fields || fields.actual === fields.expected) return null;
131
+ return { kind: "value_mismatch", hfId, ...fields };
132
+ }
133
+
134
+ /**
135
+ * Dispatch PatchOperations to the SDK session and return a parity report.
136
+ *
137
+ * If the element is not found by hfId, returns dispatched:false with a
138
+ * element_not_found mismatch (signals blocker E — element has no hf-id or
139
+ * SDK can't address it).
140
+ *
141
+ * On success, verifies that the SDK element snapshot reflects the applied
142
+ * values. Value mismatches indicate serialization or normalization drift.
143
+ *
144
+ * **persist:error drift risk**: the HTTP adapter fires persist:error on
145
+ * network failure but the SDK session is already mutated at that point. If
146
+ * the server file was not updated (e.g. 503), subsequent shadow parity
147
+ * comparisons here will see a diverged SDK session and produce false
148
+ * positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
149
+ * window is clear of persist:error events.
150
+ */
151
+
152
+ export function sdkShadowDispatch(
153
+ session: Composition,
154
+ hfId: string,
155
+ ops: PatchOperation[],
156
+ ): SdkShadowResult {
157
+ if (!session.getElement(hfId)) {
158
+ return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
159
+ }
160
+ try {
161
+ const sdkOps = patchOpsToSdkEditOps(hfId, ops);
162
+ session.batch(() => {
163
+ for (const op of sdkOps) session.dispatch(op);
164
+ });
165
+ } catch (err) {
166
+ return {
167
+ dispatched: false,
168
+ mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
169
+ };
170
+ }
171
+ const flat = flattenSnapshot(session.getElement(hfId));
172
+ const mismatches = ops
173
+ .map((op) => checkOpParity(op, flat, hfId))
174
+ .filter((m): m is SdkShadowMismatch => m !== null);
175
+ return { dispatched: true, mismatches };
176
+ }
177
+
178
+ // ─── Telemetry reporting ──────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
182
+ * Despite the telemetry focus, this function does mutate the SDK session — it
183
+ * is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
184
+ */
185
+ export function runShadowDispatch(
186
+ session: Composition,
187
+ selection: DomEditSelection,
188
+ ops: PatchOperation[],
189
+ ): void {
190
+ if (!STUDIO_SDK_SHADOW_ENABLED) return;
191
+ const hfId = selection.hfId;
192
+ if (!hfId) {
193
+ trackStudioEvent("sdk_shadow_dispatch", {
194
+ dispatched: false,
195
+ reason: "no_hf_id",
196
+ mismatchCount: 0,
197
+ });
198
+ return;
199
+ }
200
+ const result = sdkShadowDispatch(session, hfId, ops);
201
+ trackStudioEvent("sdk_shadow_dispatch", {
202
+ dispatched: result.dispatched,
203
+ mismatchCount: result.mismatches.length,
204
+ mismatches: JSON.stringify(result.mismatches),
205
+ });
206
+ }