form-graph-port

v2026.09.24

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.

GitHub
安装命令
npx skhub add civitai/form-graph-port
Markdown
SKILL.md

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.

0. Read the lib's own guidance first

C:\work\form-graph\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. assertDifferential pattern: 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.

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 tagged branch(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 (see the v1 createLocalStorageAdapter config in GenerationFormProvider.tsx for the pattern) 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/shared/form-graph/generation/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.

Keeping parity during the dual-graph window

Until the old graph is deleted, EVERY merge from main needs: git diff HEAD...origin/main --stat -- src/shared/data-graph — then mirror each change into the port AND add a differential shape covering the changed path. The suites only catch drift where shapes exercise it: krea2's community-checkpoint fix (2026-09-03) passed parity under BOTH the old and new fallback because no shape used an unknown model id. A mirrored change without a new shape is unverified.

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 (see selector-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 object and read loosely inside.

  • useForm must 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 prepack in the lib, or the link: consumer runs stale dist.

  • Pre-PR: publish the lib as ONE version, swap link: → ^x.y.z, remove any turbopack.root widening / tsconfig react paths pin added for the link, re-run the full battery.

发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

Sep 24, 2026

分类

未分类

许可证

Apache-2.0

源路径

.claude/skills/form-graph-port

默认分支

main

最新提交

2bc8fe0

Tree SHA

5997db0