SpectreSkills
← All skills

flow-versioning-and-portability

v1.0.0

Version a flow graph and move it between accounts — a revision per save with a deterministic structural diff, optimistic concurrency, restores that write forward, and an export that carries descriptors instead of credentials. Trigger when adding history, revisions, rollback, or import/export to a flow builder.

Flow Buildersflow-builderversioningdiffimport-export
Install
npx @spectre-apps/skills add flow-versioning-and-portability

Writes .claude/skills/flow-versioning-and-portability/SKILL.md. Add --user to install globally.

What it does

Versioning and moving a flow

A flow graph is a document people edit all day and occasionally need to undo. Two features fall out of that: a revision per save with a readable diff, and an export that can be imported into another account without carrying anything it shouldn't.

A revision holds the complete state after the write

Append-only, one row per mutation, each holding the whole graph — not a patch. Storage is cheap and reconstruction from a chain of patches is not. Each row carries: revision, the flow version, name, status, the graph, a headline, an optional label, the author, restoredFromRevision, and a timestamp.

Gate the write on the shared comparison:

if (!graphChanged && !metaChanged && restoredFrom === undefined) {
  return { revision: lastRevision, wrote: false, headline: "no change" };
}

The client's dirty badge must use the same graphsDiffer (see flow-graph-model), or pressing Save on an unchanged flow leaves a badge that can never clear. Return wrote so the UI can say "Saved" versus "No changes to save" honestly.

Optimistic concurrency, surfaced not hidden

The client sends the revision it started from; the server compares it to the latest inside the same transaction, and conflicts on mismatch:

throw new ConflictException({ your_revision: baseRevision, current_revision: lastRevision });

Name both numbers in the message — "You are on r41; it is now r43. Reload to pick up the newer version." Never merge two graphs automatically: a silent merge of two people's branch edits is unreviewable. See destructive-action-confirmations.

A unique index on (flow_id, revision) is what makes concurrent saves serialize rather than interleave.

The diff is structural and deterministic

Never diff the JSON text. Diff the model, and bucket changes so a timeline card tells the truth:

export type NodeChangeKind = "added" | "removed" | "moved" | "changed" | "renamed";
  • A position-only change is moved, never changed. Otherwise dragging a card reads as a config edit and every card in the timeline lies.
  • Match keyed arrays by member key, not index. Deleting the first branch of three otherwise reports every branch as changed.
  • Recurse into nested groups with a breadcrumb, so a change inside a group is attributed to the group and the step.
  • Label config keys. A Record<string, string> from payload_template to "Payload fields" is the difference between a readable timeline and raw JSON paths.
  • Headlines are deterministic — no model call. They render on every card: added Wait, Deliver +2, removed 3 steps, changed Rule branch · Branches. Cap the names listed and count the rest.

History belongs in the rail, loaded on open

Dozens of revisions with full diffs is real payload; paying for it on every builder load to fill a panel nobody opened is the trade to avoid. Fetch it when the rail opens — and keep the graph being edited mounted behind it, which is the reason history is a rail and not a route.

Each row: r41, a current badge on the newest, the label, restored r38 when it is a rollback, the headline, then timestamp · v3 · active · author, and the first few diff lines with their kind in the kind's colour. Keep that colour map exhaustive over NodeChangeKind so a new kind is a compile error rather than an unstyled row.

Restore writes forward

Restoring writes forward — it lands as a new revision, so a rollback is itself
undoable. Runs already in flight keep the graph they started on.

That sentence is the design. A restore reads the target revision, refuses if that revision's graph no longer parses, and saves it as a new revision stamped with what it restored from. Later revisions stay. Because it is reversible, it needs no modal — but it does need two things:

  • an inline warning when there are unsaved edits ("Restoring discards them"), and
  • discard the session draft before navigating, or the draft-restore effect puts the old edits straight back over the revision just restored, which reads as "restore did nothing".

Offer preserve_status so restoring a graph into a live flow does not accidentally re-activate a paused one.

Export carries the graph, never the account

The graph travels; org-scoped ids don't. Connections and destinations are per-account rows, so an export records a descriptor beside each referenced id — and never reads the encrypted config column.

const doc = {
  document_version: 1,
  exported_at,
  flow: { name, status, graph },
  connections: [{ id, name, type }],   // descriptors only
  destinations: [{ id, name, type }],
};

Collect refs by walking the graph, recursing into groups. A new node type that holds a reference must be added to both collectRefs and rewriteRefs — otherwise an export carries that id verbatim into another account, where it fails open at runtime: enriching nothing, looking fine.

Import resolves by a stated precedence, and never guesses

explicit user choice  >  same id exists here  >  unique type+name match  >  cleared
return { ...base, targetId: null, reason: "cleared", ambiguous: candidates.length > 1 };
  • Ambiguity is never resolved automatically. Two destinations of the same type and name means the import reports it and asks.
  • An unresolved reference is cleared, not carried. A foreign uuid left in place fails open at runtime.
  • Clearing differs by field: a connection becomes '' (the schema's valid work-in-progress state), while an unmatched destination is dropped from its deliver node — an unresolvable send target must not silently become a send to nowhere.
  • Show the resolution table before writing, with each row's reason. The mapping function is pure and shared, so the preview and the write are literally the same code.

Version the document (document_version) and validate it on the way in with the same schema that produced it.

Run snapshots are the fourth copy

Remember that a graph lives in the flow row, in every history revision, in every export, and in every pinned run snapshot. A run keeps the graph it started on, which is what makes a mid-journey edit safe — and is why config schemas normalize legacy shapes on read forever rather than migrating (see flow-graph-model).