mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22bb6737c5
commit
7010edac85
@@ -0,0 +1,46 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
export interface FsAdapterOptions {
|
||||
/** Root directory for composition files */
|
||||
root: string;
|
||||
}
|
||||
|
||||
// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage).
|
||||
// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers).
|
||||
|
||||
class FsAdapter implements PersistAdapter {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(opts: FsAdapterOptions) {
|
||||
this.root = opts.root;
|
||||
}
|
||||
|
||||
async read(_path: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async write(_path: string, _content: string): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
|
||||
return new FsAdapter(opts);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
|
||||
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
|
||||
class HeadlessPreviewAdapter implements PreviewAdapter {
|
||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {}
|
||||
|
||||
commitPreview(): void {}
|
||||
|
||||
cancelPreview(): void {}
|
||||
|
||||
select(_ids: string[], _opts?: { additive?: boolean }): void {}
|
||||
|
||||
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeadlessAdapter(): PreviewAdapter {
|
||||
return new HeadlessPreviewAdapter();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
class MemoryAdapter implements PersistAdapter {
|
||||
private readonly store = new Map<string, string>();
|
||||
private readonly history = new Map<string, PersistVersionEntry[]>();
|
||||
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private versionCounter = 0;
|
||||
private faultMessage: string | null = null;
|
||||
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
return this.store.get(path);
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
if (this.faultMessage !== null) {
|
||||
const msg = this.faultMessage;
|
||||
this.faultMessage = null;
|
||||
this.errorListeners.forEach((l) => l({ error: { message: msg } }));
|
||||
return;
|
||||
}
|
||||
this.store.set(path, content);
|
||||
const hist = this.history.get(path) ?? [];
|
||||
const entry: PersistVersionEntry = {
|
||||
key: `v${++this.versionCounter}`,
|
||||
content,
|
||||
};
|
||||
hist.unshift(entry);
|
||||
this.history.set(path, hist);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Memory adapter writes are synchronous — nothing to drain.
|
||||
}
|
||||
|
||||
async listVersions(path: string): Promise<PersistVersionEntry[]> {
|
||||
return [...(this.history.get(path) ?? [])];
|
||||
}
|
||||
|
||||
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
|
||||
const hist = this.history.get(path) ?? [];
|
||||
return hist.find((v) => v.key === versionKey)?.content;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper — next write fires persist:error instead of committing */
|
||||
injectFault(message: string): void {
|
||||
this.faultMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void } {
|
||||
return new MemoryAdapter();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* T13 — PersistAdapter contract suite
|
||||
*
|
||||
* Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP)
|
||||
* runs the same suite automatically — write once, protect all.
|
||||
*
|
||||
* Run against the memory adapter immediately; future implementations:
|
||||
* runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir }))
|
||||
* runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix }))
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMemoryAdapter } from "./memory.js";
|
||||
import type { PersistAdapter } from "./types.js";
|
||||
|
||||
export function runPersistAdapterContract(
|
||||
label: string,
|
||||
createAdapter: () => PersistAdapter,
|
||||
): void {
|
||||
describe(`PersistAdapter contract — ${label}`, () => {
|
||||
it("read returns undefined for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.read("missing.html")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("write then read returns the written content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "<html></html>");
|
||||
expect(await adapter.read("comp.html")).toBe("<html></html>");
|
||||
});
|
||||
|
||||
it("second write overwrites the first", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
expect(await adapter.read("comp.html")).toBe("v2");
|
||||
});
|
||||
|
||||
it("flush() returns after any pending writes are committed", async () => {
|
||||
const adapter = createAdapter();
|
||||
// Write without awaiting to exercise the queue path
|
||||
void adapter.write("comp.html", "queued");
|
||||
await adapter.flush();
|
||||
expect(await adapter.read("comp.html")).toBe("queued");
|
||||
});
|
||||
|
||||
it("listVersions returns entries in reverse-chronological order", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
await adapter.write("comp.html", "v3");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
expect(versions.length).toBeGreaterThanOrEqual(3);
|
||||
// Newest first
|
||||
expect(versions[0]?.content).toBe("v3");
|
||||
expect(versions[versions.length - 1]?.content).toBe("v1");
|
||||
});
|
||||
|
||||
it("loadFrom restores the model to that version's content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
const firstKey = versions[versions.length - 1]?.key;
|
||||
expect(firstKey).toBeDefined();
|
||||
await adapter.write("comp.html", "v2");
|
||||
const restored = await adapter.loadFrom("comp.html", firstKey!);
|
||||
expect(restored).toBe("v1");
|
||||
});
|
||||
|
||||
it("listVersions returns empty array for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.listVersions("missing.html")).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadFrom returns undefined for an unknown version key", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "content");
|
||||
expect(await adapter.loadFrom("comp.html", "nonexistent-key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on('persist:error') fires when a write fails; error is not thrown", async () => {
|
||||
// This test uses the injectFault() test helper if available.
|
||||
// For adapters without fault injection, skip with a note.
|
||||
const adapter = createAdapter();
|
||||
const hasInjectFault =
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function";
|
||||
|
||||
if (!hasInjectFault) {
|
||||
// Adapter does not expose fault injection — skip execution
|
||||
// (test still runs to document the contract; real adapters must implement this)
|
||||
return;
|
||||
}
|
||||
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
(adapter as { injectFault(m: string): void }).injectFault("network error");
|
||||
|
||||
await adapter.write("comp.html", "content");
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ message: "network error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribe returned by on() removes the listener", async () => {
|
||||
const adapter = createAdapter();
|
||||
const onError = vi.fn();
|
||||
const unsub = adapter.on("persist:error", onError);
|
||||
unsub();
|
||||
|
||||
// Fire an error if possible
|
||||
if (
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function"
|
||||
) {
|
||||
(adapter as { injectFault(m: string): void }).injectFault("err");
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run the suite against the memory adapter immediately
|
||||
runPersistAdapterContract("memory", createMemoryAdapter);
|
||||
@@ -1,5 +1,3 @@
|
||||
// Consumed by session.ts + adapter implementations in the next stacked PR (#1325).
|
||||
// fallow-ignore-file unused-file
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user