Form Graph Port
Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the…
by civitai · Apache-2.0
The skill
Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the form-graph library. Use when asked to move a form's field logic, branching, persistence, or server validation onto form-graph. Encodes the method proven by the generation-form port — oracle-first differential testing, scope mapping, staged cutover.
Porting a form to form-graph
The method that took the generation form (~45 family graphs, 4 output types, 7
standalone workflows, ~12k differential cases) onto form-graph, distilled so the next
port (e.g. model training) doesn't rediscover it. The worked example is
src/shared/form-graph/generation/ + docs/form-graph-port-plan.md; read the plan
doc's phase structure before starting anything sizable. That port is COMPLETE and its
oracle is deleted, so the tree shows the end state, not the dual-engine window — the
harness and differential suites are only in git history.
0. Read the lib's own guidance first
The form-graph library repo's own CLAUDE.md carries the library's design invariants (one branch
combinator, sync resolution, wire-named computedKeys, the prepack-after-every-edit rule
for link: consumers). Don't design against an imagined API.
1. Identify the oracle, then build the harness FIRST
Nothing else starts until parity is measurable.
- Oracle = whatever produces today's wire payload. For a data-graph form it's
graph.safeParse. For a bespoke form (training:src/components/Training/Wizard+src/server/schema/training.schema.ts+ the orchestrator validation) it's the submit payload builder — capture real input→payload fixtures if there's no parse function. - Differential = byte-identical wire.
assertDifferentialpattern: port parse vs oracle parse over generated cases, plus the parse-fixpoint pin (re-parse the port's own state → identical data; this is what makes whatIf/cost preview trustworthy). - Bound every generated-case driver — a fake that pages/loops must terminate on its own (see CLAUDE.md's microtask-loop warning; a hang is unreportable in vitest).
- Divergences found by the harness are findings to record, not always bugs — v1 does have dead paths and quirks. Pin the deliberate deltas in a comment or the plan doc.
- A mirrored change without a new shape is unverified. While the oracle still exists, every change to it must be mirrored into the port AND get a differential shape that exercises the changed path. The generation port's krea2 community-checkpoint fix (2026-09-03) passed parity under BOTH the old and new fallback, because no shape used an unknown model id.
2. Structure: declare-then-dispatch
- Discriminators are ordinary fields declared above the dispatch:
.field('ecosystem', def)then.use(branch('ecosystem', [[keys, member], ...] as const)). Group related keys into one pair — arm count should equal family count, not key count. - State-only discriminators (never on the wire) are computeds with
{ emit: false }fed to the taggedbranch(key, pick, members, { emit: false })form. - Shared per-family plumbing goes in a
shared.ts(familyScope, text-block factories,modelIdOf-style raw-or-parsed readers — store state holds RAW inputs, so anything reading ctx must accept both shapes).
3. Storage: map the old adapter groups to scopes
Translate the legacy storage-adapter groups (the deleted v1 createLocalStorageAdapter
config — recoverable from git history at src/components/generation_v2/GenerationFormProvider.tsx)
into graph/field scope
declarations: graph-level scope for family buckets, rootScope() to opt a field out
to global memory, rootScope(workflow) for per-workflow buckets, relative [modelId]
appends for per-variant refinements. One persisted record per form
(persistedStorage('<key>')).
4. Types: extract, never re-declare
InferData / InferArm / InferLooseData from the graph type the handlers
(EcosystemData<'X'> pattern in src/server/services/orchestrator/form-graph/types.ts). Zero
as never; a residual cast marks a provably-dead path and says so. After type-level
work, measure compiler cost against main (tsc --extendedDiagnostics, delete
tsconfig.tsbuildinfo, NODE_OPTIONS=--max_old_space_size=12288 — default heap OOMs).
5. Stored-value migration (if old users' settings should survive)
Consumer-side module (migrate-v1-storage.ts is the template): read the old records,
pick ONLY the fields worth carrying, build one address→raw-value record with
scopedAddress, write it once iff the new key is absent. Values go in raw — the input
schemas validate on first resolve, so stale garbage degrades to defaults. Never delete
the old records while anything still reads them.
6. Cutover: ONE feature flag, always-on comparison
One feature flag (availability: ['mod'] first, widened via its Flipt key) gates the
whole cutover per user: it swaps the form component on the client AND serves the
port's parse on the server (read from the ctx the feature-flag system already
threads — coerce it, a sparse record reads undefined). Every server parse runs BOTH
engines regardless and records the comparison — outcomes counted
(registerCounterWithLabels), divergence logged with diff keys only — never field
values (user content must not reach logs; pin that with a test, one sentinel per
emit path). Comparison noise is fine: it dies with the old engine.
Flag off must be byte-identical. The generation port briefly used three flags (separate shadow/serve Flipt switches) and collapsed them once the parity battery made independent server/client rollback unnecessary — start with one. Deleting the old engine is a separate change after the flag is fully widened.
Gotchas that cost real time on the generation port
-
Cross-field coherence (a selection retargeting another selection) belongs in a RULE on the graph (
.effect({...})— gesture-aware, fires before resolution, covers every writer), NOT in transcribed v1 UI handlers. v1 kept it in handlers because data-graph had no rules; transcribing that architecture reintroduced a crash the graph could have prevented (seeselector-coherence.ts). -
The port parse composes as
hub.parse(reconcileSelectors(raw).raw, ext)— selector reconciliation is part of the parse contract, not optional plumbing. -
A generic helper over union arms hits TS's weak-type rule when one arm shares no properties — constrain
T extends objectand read loosely inside. -
useFormmust be typed to preserve the store's full type (Store extends FormStore<any, Ext, any, any>), or per-arm emits break DataOf≡Ctx at mounts. -
After every form-graph lib edit:
pnpm run prepackin the lib, or thelink:consumer runs stale dist. -
Pre-PR: publish the lib as ONE version, swap
link:→^x.y.z, remove anyturbopack.rootwidening / tsconfigreactpaths pin added for the link, re-run the full battery.
Use this skill
Connect your agent to the Skill Store MCP server and ask for it by ref: civitai/form-graph-port-39038216233.
Details
- Publisher
- civitai
- Category
- Software Development › Frontend & UI
- License
- Apache-2.0
- Updated
- 2026-10-19
References
More from civitai
- Add Ecosystem Add a new ecosystem and base model to basemodel.constants.ts.
- Add Generation Support Wire an existing ecosystem into the generation system.
- Add Prompt Enhancement Guide Author a prompt-enhancement system prompt for a new ecosystem and register/update it on the orche…
- Add Trainer Model Add a new trainable base model to BOTH trainers end-to-end — the in-app trainer (main Next.js app…