feat(sdk): ws-3 — reorderElements op (batch z-index update) (#1502)

* feat(sdk): ws-3 — reorderElements op (batch z-index update)

Adds the reorderElements EditOp: each entry sets inline zIndex on one element.
Last-write-wins per target so a duplicated target collapses to a single zIndex
patch. Positioning is unchanged — z-index only takes effect on non-static
elements, so the caller must ensure the target is positioned.

Also fixes single-dispatch undo to reverse the inverse patch list (parity with
batch()): an op emitting multiple patches whose undo order matters — a duplicated
reorderElements target, an aliased multi-target, or a nested parent+child
removeElement — must undo in reverse application order, or undo lands on an
intermediate value / drops a subtree.

validateOp resolves every entry target (E_TARGET_NOT_FOUND for unknown ids;
empty entries is a clean no-op). Tests cover set/inverse/validate/duplicate-target.

Rebuilt standalone on main (reorderElements only depends on handleSetStyle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sdk): remove unused HTTP persist adapter

The HTTP PersistAdapter (createHttpAdapter) was dead weight after the studio
cutover went single-writer (ws-4): Studio's writeProjectFile is the sole writer
and useSdkSession opens with no persist queue, so the adapter's write/flush/
listVersions/loadFrom were never used — only read() was, to fetch the
composition source. Replace those two read() calls with a direct optional fetch
(GET /files/<path>?optional=1) and drop the adapter + its export-map entries.

Saved for later re-introduction (when a non-Studio SDK host needs server-backed
persist) at docs/hyperframes/plans/sdk-http-adapter/ (outside the repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): resolver shadow for z-index reorder targets

The z-index reorder commit takes the server path (no SDK persist), but the
resolver-shadow tripwire is decoupled from cutover — so it should still record
whether the SDK resolves each reordered element (reorderElements' targets), the
same as timing/delete already do before their cutover gate. This gives wild
resolver-parity telemetry on z-index targets before z-index reorder is cut over.

Threads an onReorderShadow callback (sdkSession-bound, mirrors onTrySdkDelete)
from useDomEditSession → useDomEditCommits → useElementLifecycleOps, called with
the reordered elements' hf-ids in handleDomZIndexReorderCommit. Read-only,
divergence-only, never throws — same contract as recordResolverParity elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): guard project-file read path against traversal (CodeQL CSRF)

readProjectFileOptional interpolated a user-influenced composition path into the
fetch URL, which CodeQL flagged as client-side request forgery. Reject NUL/`..`
up front (mirrors the existing guard in timelineEditingHelpers) and
encodeURIComponent the projectId too, so both values stay confined to single
segments of the same-origin URL. Unsafe path → undefined (graceful for the
optional read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-17 23:51:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent de87f3932e
commit 28bfe09f21
11 changed files with 154 additions and 452 deletions
-8
View File
@@ -29,10 +29,6 @@
"./adapters/headless": {
"import": "./src/adapters/headless.ts",
"types": "./src/adapters/headless.ts"
},
"./adapters/http": {
"import": "./src/adapters/http.ts",
"types": "./src/adapters/http.ts"
}
},
"publishConfig": {
@@ -53,10 +49,6 @@
"./adapters/headless": {
"import": "./dist/adapters/headless.js",
"types": "./dist/adapters/headless.d.ts"
},
"./adapters/http": {
"import": "./dist/adapters/http.js",
"types": "./dist/adapters/http.d.ts"
}
},
"main": "./dist/index.js",
-313
View File
@@ -1,313 +0,0 @@
/**
* Unit tests for createHttpAdapter.
*
* Mocks global `fetch` to verify URL construction, method/headers, error routing,
* and flush semantics without a real server.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createHttpAdapter } from "./http.js";
const BASE = "/api/projects/proj-abc";
// ── fetch mock helpers ────────────────────────────────────────────────────────
function stubFetch(
handler: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body?: unknown },
): ReturnType<typeof vi.fn> {
const mock = vi.fn(async (url: string, init?: RequestInit) => {
const r = handler(url, init);
return {
ok: r.ok,
status: r.status ?? (r.ok ? 200 : 500),
json: async () => r.body ?? {},
};
});
vi.stubGlobal("fetch", mock);
return mock;
}
beforeEach(() => {
stubFetch(() => ({ ok: true, body: { content: "" } }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ── read() ────────────────────────────────────────────────────────────────────
describe("read()", () => {
it("fetches the correct URL with ?optional=1", async () => {
const mock = stubFetch(() => ({ ok: true, body: { content: "<html/>" } }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
await adapter.read("comp.html");
expect(mock).toHaveBeenCalledWith(
`${BASE}/files/${encodeURIComponent("comp.html")}?optional=1`,
);
});
it("returns content on success", async () => {
stubFetch(() => ({ ok: true, body: { content: "<html>hello</html>" } }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.read("comp.html")).toBe("<html>hello</html>");
});
it("returns undefined when response body lacks content field", async () => {
stubFetch(() => ({ ok: true, body: {} }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.read("missing.html")).toBeUndefined();
});
it("returns undefined on non-ok response", async () => {
stubFetch(() => ({ ok: false, status: 404 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.read("gone.html")).toBeUndefined();
});
});
// ── write() ───────────────────────────────────────────────────────────────────
describe("write()", () => {
it("PUTs to the correct URL with text/plain body", async () => {
const mock = stubFetch(() => ({ ok: true }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
await adapter.write("comp.html", "<html>new</html>");
expect(mock).toHaveBeenCalledWith(
`${BASE}/files/${encodeURIComponent("comp.html")}`,
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ "Content-Type": "text/plain" }),
body: "<html>new</html>",
}),
);
});
it("fires persist:error on non-ok response without throwing", async () => {
stubFetch(() => ({ ok: false, status: 503 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const onError = vi.fn();
adapter.on("persist:error", onError);
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.objectContaining({ message: "HTTP 503" }) }),
);
});
it("fires persist:error on network error without throwing", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network down")));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const onError = vi.fn();
adapter.on("persist:error", onError);
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ message: expect.stringContaining("network down") }),
}),
);
});
it("does not fire persist:error on success", async () => {
stubFetch(() => ({ ok: true }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const onError = vi.fn();
adapter.on("persist:error", onError);
await adapter.write("comp.html", "x");
expect(onError).not.toHaveBeenCalled();
});
});
// ── headers option ───────────────────────────────────────────────────────────
describe("headers option", () => {
it("merges static headers into every PUT request", async () => {
const mock = stubFetch(() => ({ ok: true }));
const adapter = createHttpAdapter({
projectFilesUrl: BASE,
headers: { Authorization: "Bearer tok" },
});
await adapter.write("comp.html", "x");
expect(mock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer tok" }),
}),
);
});
it("calls a headers function lazily on each write", async () => {
const mock = stubFetch(() => ({ ok: true }));
let n = 0;
const adapter = createHttpAdapter({
projectFilesUrl: BASE,
headers: () => ({ Authorization: `Bearer tok${++n}` }),
});
await adapter.write("comp.html", "a");
await adapter.write("comp.html", "b");
const calls = mock.mock.calls.filter((c) => c[1]?.method === "PUT");
expect((calls[0][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok1");
expect((calls[1][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok2");
});
});
// ── flush() ───────────────────────────────────────────────────────────────────
describe("flush()", () => {
it("resolves immediately when no writes are in flight", async () => {
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
await expect(adapter.flush()).resolves.toBeUndefined();
});
it("waits for an in-flight write before resolving", async () => {
let resolveFetch!: () => void;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if (init?.method === "PUT") {
await new Promise<void>((r) => {
resolveFetch = r;
});
}
return { ok: true, status: 200, json: async () => ({}) };
}),
);
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
void adapter.write("comp.html", "x"); // intentionally not awaited
await Promise.resolve(); // let path-queue microtask fire so doWrite starts
let flushed = false;
const flushDone = adapter.flush().then(() => {
flushed = true;
});
expect(flushed).toBe(false);
resolveFetch();
await flushDone;
expect(flushed).toBe(true);
});
it("waits for two concurrent in-flight writes before resolving", async () => {
const resolvers: Array<() => void> = [];
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if (init?.method === "PUT") {
await new Promise<void>((r) => resolvers.push(r));
}
return { ok: true, status: 200, json: async () => ({}) };
}),
);
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
void adapter.write("a.html", "1");
void adapter.write("b.html", "2");
await Promise.resolve(); // let both start
await Promise.resolve();
let flushed = false;
const flushDone = adapter.flush().then(() => {
flushed = true;
});
expect(flushed).toBe(false);
resolvers[0]();
await Promise.resolve();
expect(flushed).toBe(false); // still waiting for second write
resolvers[1]();
await flushDone;
expect(flushed).toBe(true);
});
});
// ── listVersions() / loadFrom() ───────────────────────────────────────────────
describe("listVersions()", () => {
it("returns empty array (server versioning not exposed by this adapter)", async () => {
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.listVersions("comp.html")).toEqual([]);
});
});
describe("loadFrom()", () => {
it("returns undefined (server versioning not exposed by this adapter)", async () => {
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.loadFrom("comp.html", "v1")).toBeUndefined();
});
});
// ── write() — per-path serialization ─────────────────────────────────────────
describe("write() — per-path serialization", () => {
it("serializes concurrent writes to the same path (second waits for first)", async () => {
const starts: number[] = [];
let resolveFirst!: () => void;
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if (init?.method === "PUT") {
const n = ++callCount;
starts.push(n);
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
}
return { ok: true, status: 200, json: async () => ({}) };
}),
);
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const write1 = adapter.write("comp.html", "v1");
await Promise.resolve(); // let write1 start
const write2 = adapter.write("comp.html", "v2");
await Promise.resolve(); // let write2 attempt to start
expect(starts).toEqual([1]); // write2 has NOT started yet
resolveFirst();
await write1;
await write2;
expect(starts).toEqual([1, 2]); // write2 started only after write1 finished
});
it("does not block writes to different paths", async () => {
const starts: string[] = [];
let resolveFirst!: () => void;
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
if (init?.method === "PUT") {
const n = ++callCount;
starts.push(`${n}:${url.split("/").pop()}`);
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
}
return { ok: true, status: 200, json: async () => ({}) };
}),
);
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const write1 = adapter.write("a.html", "v1");
await Promise.resolve();
void adapter.write("b.html", "v2"); // different path — must not wait for write1
await Promise.resolve();
expect(starts.length).toBe(2); // both started concurrently
resolveFirst();
await write1;
});
});
// ── on() / unsubscribe ────────────────────────────────────────────────────────
describe("on() / unsubscribe", () => {
it("unsubscribe removes the listener", async () => {
stubFetch(() => ({ ok: false, status: 500 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const onError = vi.fn();
const unsub = adapter.on("persist:error", onError);
unsub();
await adapter.write("comp.html", "x");
expect(onError).not.toHaveBeenCalled();
});
it("multiple listeners all fire", async () => {
stubFetch(() => ({ ok: false, status: 500 }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
const a = vi.fn();
const b = vi.fn();
adapter.on("persist:error", a);
adapter.on("persist:error", b);
await adapter.write("comp.html", "x");
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
});
});
-111
View File
@@ -1,111 +0,0 @@
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
import type { PersistErrorEvent } from "../types.js";
export interface HttpAdapterOptions {
/**
* Base URL for the project files REST API, no trailing slash.
* E.g. "/api/projects/proj-abc"
*/
projectFilesUrl: string;
/**
* Extra headers to include on every PUT write request.
* Pass a function to compute them lazily (e.g. to refresh a bearer token on each request).
* Useful for cross-origin or CLI contexts where ambient cookies are not available.
*/
headers?: HeadersInit | (() => HeadersInit);
}
class HttpAdapter implements PersistAdapter {
private readonly baseUrl: string;
private readonly extraHeaders?: HttpAdapterOptions["headers"];
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
private readonly inflightWrites = new Set<Promise<void>>();
private readonly pathQueues = new Map<string, Promise<void>>();
constructor(opts: HttpAdapterOptions) {
this.baseUrl = opts.projectFilesUrl;
this.extraHeaders = opts.headers;
}
async read(path: string): Promise<string | undefined> {
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`;
const res = await fetch(url);
if (!res.ok) return undefined;
const data = (await res.json()) as { content?: string };
return typeof data.content === "string" ? data.content : undefined;
}
/**
* Enqueue a write for path. Same-path writes are serialized via pathQueues
* so concurrent saves never interleave. Each write is a single-shot PUT —
* on network error or non-2xx response, persist:error fires and the write
* is not retried. Retry is the caller's responsibility.
*/
async write(path: string, content: string): Promise<void> {
const prev = this.pathQueues.get(path) ?? Promise.resolve();
const p = prev.then(() => this.doWrite(path, content));
this.pathQueues.set(
path,
p.catch(() => {}),
);
this.inflightWrites.add(p);
try {
await p;
} finally {
this.inflightWrites.delete(p);
}
}
private async doWrite(path: string, content: string): Promise<void> {
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`;
let res: Response;
try {
const extra =
typeof this.extraHeaders === "function" ? this.extraHeaders() : this.extraHeaders;
res = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "text/plain", ...extra },
body: content,
});
} catch (err) {
this.fireError(String(err), err);
return;
}
if (!res.ok) {
this.fireError(`HTTP ${res.status}`);
}
}
async flush(): Promise<void> {
await Promise.all([...this.inflightWrites]);
}
/** Server-side versioning is not exposed by this adapter; returns [] intentionally. */
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
return [];
}
/** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
return undefined;
}
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
if (event !== "persist:error") return () => {};
this.errorListeners.push(handler);
return () => {
const idx = this.errorListeners.indexOf(handler);
if (idx !== -1) this.errorListeners.splice(idx, 1);
};
}
private fireError(message: string, cause?: unknown): void {
const error: PersistErrorEvent["error"] =
cause !== undefined ? { message, cause } : { message };
for (const l of this.errorListeners) l({ error });
}
}
export function createHttpAdapter(opts: HttpAdapterOptions): PersistAdapter {
return new HttpAdapter(opts);
}
+66
View File
@@ -565,3 +565,69 @@ describe("setCompositionMetadata data-* channel", () => {
expect(root?.getAttribute("style")).toContain("width: 1920px");
});
});
// ─── reorderElements ─────────────────────────────────────────────────────────
describe("reorderElements", () => {
it("sets zIndex on each entry", () => {
const parsed = fresh();
applyOp(parsed, {
type: "reorderElements",
entries: [
{ target: "hf-title", zIndex: 2 },
{ target: "hf-logo", zIndex: 1 },
],
});
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
const logo = parsed.document.querySelector("[data-hf-id='hf-logo']") as HTMLElement | null;
expect(title?.style.zIndex).toBe("2");
expect(logo?.style.zIndex).toBe("1");
});
it("inverse restores original zIndex values", () => {
const parsed = fresh();
const before = serializeDocument(parsed);
const { inverse } = applyOp(parsed, {
type: "reorderElements",
entries: [{ target: "hf-title", zIndex: 5 }],
});
applyPatchesToDocument(parsed, inverse);
expect(serializeDocument(parsed)).toBe(before);
});
it("validateOp returns ok:true for existing targets", () => {
const r = validateOp(fresh(), {
type: "reorderElements",
entries: [{ target: "hf-title", zIndex: 1 }],
});
expect(r.ok).toBe(true);
});
it("validateOp returns E_TARGET_NOT_FOUND for unknown target", () => {
const r = validateOp(fresh(), {
type: "reorderElements",
entries: [{ target: "hf-unknown", zIndex: 1 }],
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
});
it("duplicate target collapses to last-wins and inverse restores cleanly", () => {
const parsed = fresh();
const before = serializeDocument(parsed);
const { forward, inverse } = applyOp(parsed, {
type: "reorderElements",
entries: [
{ target: "hf-title", zIndex: 2 },
{ target: "hf-title", zIndex: 9 },
],
});
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
expect(title?.style.zIndex).toBe("9"); // last write wins
expect(forward.length).toBe(1); // one patch, not two on the same path
// Inverse must be applied in reverse order (session reverses single-dispatch
// inverse) to land back on the original, not the intermediate "2".
applyPatchesToDocument(parsed, [...inverse].reverse());
expect(serializeDocument(parsed)).toBe(before);
});
});
+32
View File
@@ -261,6 +261,8 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleMoveElement(parsed, targets(op.target), op.x, op.y);
case "removeElement":
return handleRemoveElement(parsed, targets(op.target));
case "reorderElements":
return handleReorderElements(parsed, op.entries);
case "setCompositionMetadata":
return handleSetCompositionMetadata(parsed, op);
case "setVariableValue":
@@ -608,6 +610,23 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
return result;
}
function handleReorderElements(
parsed: ParsedDocument,
entries: Array<{ target: HfId; zIndex: number }>,
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
// Last write wins per target — a duplicated target collapses to one zIndex
// patch instead of emitting redundant same-path patches in one dispatch.
const lastByTarget = new Map<HfId, number>();
for (const { target, zIndex } of entries) lastByTarget.set(target, zIndex);
for (const [target, zIndex] of lastByTarget) {
const sub = handleSetStyle(parsed, [target], { zIndex: String(zIndex) });
result.forward.push(...sub.forward);
result.inverse.push(...sub.inverse);
}
return result;
}
// fallow-ignore-next-line complexity
function handleSetCompositionMetadata(
parsed: ParsedDocument,
@@ -1189,6 +1208,19 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
);
return CAN_OK;
}
case "reorderElements": {
if (op.entries.length === 0) return CAN_OK;
const missing = op.entries
.map((e) => e.target)
.filter((id) => resolveScoped(parsed.document, id) === null);
if (missing.length > 0)
return canErr(
"E_TARGET_NOT_FOUND",
`Element(s) not found: ${missing.join(", ")}.`,
"Verify the id against comp.getElements() or comp.find().",
);
return CAN_OK;
}
case "setVariableValue":
if (findRoot(parsed.document) === null)
return canErr("E_NO_ROOT", "Composition root element not found.");
-2
View File
@@ -37,6 +37,4 @@ export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adap
// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs).
export { createMemoryAdapter } from "./adapters/memory.js";
export { createHeadlessAdapter } from "./adapters/headless.js";
export { createHttpAdapter } from "./adapters/http.js";
export type { HttpAdapterOptions } from "./adapters/http.js";
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
+5
View File
@@ -82,6 +82,11 @@ export type EditOp =
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
| { type: "removeElement"; target: HfId | HfId[] }
| {
type: "reorderElements";
/** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
entries: Array<{ target: HfId; zIndex: number }>;
}
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
| { type: "setVariableValue"; id: string; value: string | number | boolean }