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.
This commit is contained in:
Vance Ingalls
2026-07-09 11:52:12 -07:00
parent d32fb19b9c
commit 19756faa5d
10 changed files with 402 additions and 5 deletions
+64 -1
View File
@@ -7,7 +7,7 @@
* (engine/apply-patches.ts) can never disagree on the model's shape.
*/
type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
export type VariableDecl = { id: string; default?: unknown; [key: string]: unknown };
function getHtmlEl(document: Document): Element | null {
return (document as Document & { documentElement?: Element }).documentElement ?? null;
@@ -77,3 +77,66 @@ export function clearVariableDefault(document: Document, id: string): boolean {
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;
}