Files
hyperframes/packages/sdk/src/engine/variableModel.ts
T
Vance Ingalls 19756faa5d feat(sdk): variable CRUD (declare/remove/get/list) + export gaps
Closes the remaining Tier 2/3 gaps from the SDK surface audit that motivated
#2092 — real, contained fixes short of the two genuinely architectural items
(a live-DOM apply adapter, structural editing ops) that need their own design
pass, not a quick patch.

Variable CRUD was write-only and creation-blocked: setVariableValue existed,
but there was no getVariableValue, listVariables, declareVariable, or
removeVariable — and writeVariableDefault intentionally refuses to create an
undeclared variable ("keep the schema authoritative"), so a variables panel
(list what exists, read current values, let someone add one) could not be
built against the SDK at all.

- getVariableValue(id) / listVariables(): thin reads over the existing
  readVariableDefault / a new listVariableDecls.
- declareVariable(decl) / removeVariable(id): new EditOps with full
  undo/redo support via a new patch path (/variable-decls/{id}, distinct
  from /variables/{id} which is default-only) — removeVariable's inverse
  bundles the original array index so undo reinserts at the same position
  instead of appending, mirroring handleRemoveElement's siblingIndex.

Export gaps (same shape as #2092's fixes — the logic already existed,
just wasn't reachable): resolveScoped, findById, escapeHfId from
engine/model.ts; readVariableDefault from engine/variableModel.ts.

17 new tests across mutate.test.ts (declareVariable/removeVariable engine
semantics + undo), session.test.ts (Composition-level API), and smoke.test.ts
(export-surface import check). 439/439 sdk tests passing. Full workspace
build (incl. studio) verified clean.
2026-07-09 11:52:12 -07:00

143 lines
5.8 KiB
TypeScript

/**
* Shared helpers for the composition variable JSON model
* (`data-composition-variables` on `document.documentElement`).
*
* Single source for the parse → find-by-id → read/write/clear logic so the
* forward-mutation path (engine/mutate.ts) and the patch-replay path
* (engine/apply-patches.ts) can never disagree on the model's shape.
*/
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
function getHtmlEl(document: Document): Element | null {
return (document as Document & { documentElement?: Element }).documentElement ?? null;
}
/** Parse the variable declaration array, or null when absent/invalid. */
function readDecls(document: Document): { htmlEl: Element; arr: VariableDecl[] } | null {
const htmlEl = getHtmlEl(document);
if (!htmlEl) return null;
const raw = htmlEl.getAttribute("data-composition-variables");
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
return { htmlEl, arr: parsed as VariableDecl[] };
}
function indexOfId(arr: VariableDecl[], id: string): number {
return arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id);
}
/**
* Read the current `default` value for a variable id. Returns undefined when
* the attribute is absent, the JSON is invalid, or no entry matches the id.
*/
export function readVariableDefault(document: Document, id: string): unknown {
const decls = readDecls(document);
if (!decls) return undefined;
const idx = indexOfId(decls.arr, id);
return idx < 0 ? undefined : decls.arr[idx]?.default;
}
/**
* Upsert a variable's `default`. No-ops (returns false) when the attribute is
* absent or contains no declaration for the id — we never auto-add declarations
* for undeclared variables, keeping the schema authoritative. Returns true when
* the attribute was updated.
*/
export function writeVariableDefault(document: Document, id: string, newDefault: unknown): boolean {
const decls = readDecls(document);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return false; // variable not declared — don't auto-add
decls.arr[idx] = { ...decls.arr[idx]!, default: newDefault };
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return true;
}
/**
* Remove the `default` key from a variable declaration, restoring its
* "no authored default" state. This is the exact inverse of writeVariableDefault
* adding a default to a decl that had none, so undo of a first-set on a
* default-less variable round-trips. No-ops when the decl or key is absent.
* Returns true when the attribute was updated.
*/
export function clearVariableDefault(document: Document, id: string): boolean {
const decls = readDecls(document);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0 || !(decls.arr[idx]! && "default" in decls.arr[idx]!)) return false;
const { default: _drop, ...rest } = decls.arr[idx]!;
decls.arr[idx] = rest as VariableDecl;
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return true;
}
/** All declared variables, or [] when the attribute is absent/invalid. */
export function listVariableDecls(document: Document): VariableDecl[] {
return readDecls(document)?.arr ?? [];
}
/**
* Upsert a full variable declaration (id/type/label/default/…), unlike
* writeVariableDefault which only ever touches the `default` field of an
* ALREADY-declared variable and refuses to create new ones. This is the
* "let someone add a variable" path a declarations panel needs — creates the
* `data-composition-variables` attribute from scratch when absent.
*
* Replaces the whole existing decl when `decl.id` is already declared (so
* editing a variable's type/label/options goes through the same call as
* creating one). Returns the previous decl (for inverse-patch capture) or
* null when this was a fresh create.
*/
export function declareVariableDecl(
document: Document,
decl: VariableDecl,
opts?: { atIndex?: number },
): VariableDecl | null {
const htmlEl = getHtmlEl(document);
if (!htmlEl) return null;
const existing = readDecls(document);
const arr = existing?.arr ?? [];
const idx = indexOfId(arr, decl.id);
const previous = idx < 0 ? null : arr[idx]!;
if (idx >= 0) {
arr[idx] = decl; // edit in place — position is already preserved
} else if (opts?.atIndex !== undefined) {
// Undo of removeVariable: reinsert at the exact index it was removed
// from, so a remove-then-undo round-trips the array order, not just
// set-membership (mirrors handleRemoveElement's siblingIndex).
arr.splice(opts.atIndex, 0, decl);
} else {
arr.push(decl); // a genuinely new declaration goes to the end of the list
}
htmlEl.setAttribute("data-composition-variables", JSON.stringify(arr));
return previous;
}
/**
* Remove a variable's declaration entirely (not just its default — the whole
* schema entry). Live `var.{id}` overrides and any data-var-* DOM references
* are left untouched; removing the declaration doesn't reach into either.
* Returns the removed decl AND its array index (for inverse-patch capture, so
* undo can reinsert at the original position — mirrors handleRemoveElement's
* siblingIndex), or null when the attribute/decl was absent.
*/
export function removeVariableDecl(
document: Document,
id: string,
): { decl: VariableDecl; index: number } | null {
const decls = readDecls(document);
if (!decls) return null;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return null;
const [removed] = decls.arr.splice(idx, 1);
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return removed ? { decl: removed, index: idx } : null;
}