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
+80
View File
@@ -0,0 +1,80 @@
/**
* Optional persist queue module (F5 layering).
*
* Subscribes to 'change' events and schedules async writes via a PersistAdapter.
* One in-flight write at a time; latest state always wins (last-write-wins coalescing).
*
* Wired automatically by openComposition() in standalone (T1/T2) mode.
* T3 (embedded) hosts own persistence — do not use this module.
*/
import type { Composition } from "./types.js";
import type { PersistAdapter } from "./adapters/types.js";
export interface PersistQueueModule {
/** Force an immediate write (e.g. before app close). */
flush(): Promise<void>;
dispose(): void;
}
export interface PersistQueueOptions {
/** Adapter path to write to. Default: "composition.html" */
path?: string;
}
export function createPersistQueue(
session: Composition,
adapter: PersistAdapter,
opts: PersistQueueOptions = {},
): PersistQueueModule {
const path = opts.path ?? "composition.html";
let pendingWrite: ReturnType<typeof setTimeout> | null = null;
// Promise-chain mutex: each write chains onto the prior, preventing concurrent writes.
let writeChain: Promise<void> = Promise.resolve();
let disposed = false;
function scheduleWrite(): void {
if (pendingWrite !== null) clearTimeout(pendingWrite);
pendingWrite = setTimeout(() => {
pendingWrite = null;
void doWrite();
}, 0);
}
function doWrite(): Promise<void> {
if (disposed) return Promise.resolve();
const content = session.serialize();
writeChain = writeChain.then(async () => {
if (disposed) return;
try {
await adapter.write(path, content);
} catch {
// error already surfaced via persist:error on the adapter
}
});
return writeChain;
}
const unsubscribe = session.on("change", () => {
scheduleWrite();
});
return {
async flush(): Promise<void> {
if (pendingWrite !== null) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
await doWrite();
},
dispose(): void {
disposed = true;
if (pendingWrite !== null) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
unsubscribe();
},
};
}