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, neverchanged. 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>frompayload_templateto "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).