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:
Vance Ingalls
2026-06-11 12:23:09 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22bb6737c5
commit 7010edac85
17 changed files with 1875 additions and 7 deletions
+46
View File
@@ -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);
}
+24
View File
@@ -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();
}
+62
View File
@@ -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);
-2
View File
@@ -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 ───────────────────────────────────────────────────────────