Files
hyperframes/packages/sdk/src/persist-queue.ts
T
Vance IngallsandClaude Sonnet 4.6 a0ee97210b fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors

* test(sdk,ci): smoke test + explicit sdk-tests CI gate

Smoke test covers the full public surface:
  openComposition → setStyle/setText/dispatch(moveElement) → serialize
  applyPatches + ORIGIN_APPLY_PATCHES tagging
  batch() coalescing + transactional rollback on throw
  undo/redo round-trip
  persist adapter write + persist:error surfacing
  T3 embedded mode: override-set apply on open + getOverrides round-trip

Adds sdk-tests CI job so SDK coverage is explicitly named and required —
prevents a repeat of the demo-next vitest-never-ran incident.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): export adapter types, awaitable flush(), never-coalesce mode

- Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package
  root — callers can now write typed fakes without reaching into internals
- Add flush(): Promise<void> to Composition interface + CompositionImpl —
  app-close handlers can await a clean drain of the persist queue
- coalesceMs <= 0 disables coalescing entirely in createHistory — enables
  deterministic test scenarios without per-entry timestamp manipulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke

- setText on element with no prior text node (firstTextIdx=-1 path)
- applyOverrideSet null removal on non-existent prop is a no-op (no throw)
- smoke persist test uses comp.flush() instead of setTimeout
- can() JSDoc clarifies Phase 3b false-return is intentional feature-detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: trigger regression suite

* fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy

bun install --frozen-lockfile fails in the regression Docker build because
the lockfile references the sdk workspace member but its package.json was
not copied into the image before the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:07:49 -07:00

84 lines
2.3 KiB
TypeScript

/**
* 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, PersistErrorEvent } 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;
/** Called when adapter.write() rejects. */
onError?: (e: PersistErrorEvent) => void;
}
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 (err) {
const message = err instanceof Error ? err.message : String(err);
opts.onError?.({ error: { message, cause: err } });
}
});
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();
},
};
}