form-graph

Publish

Rung five: the hub pattern — how a generator with dozens of model families stays sane. Each destination is a complete form in its own module (S3 even exports a standalone s3Form); the hub declares the discriminator as an ordinary field and branch('destination', pairs) dispatches on it, with the option list built from the modules, so adding a destination is one import plus one pair. Each arm contributes that destination's fields — the state IS a discriminated union, and the panel below narrows it with a plain if. The shared retries key is scoped per destination: webhook and email each remember their own.

destination
region
storageClass

The union, narrowed with a plain if

Delivering to (bucket unset) in us-east-1 as standard. — `s.retries` does not compile here.

Intent — note retries@email vs retries@webhook

{}
Try: set webhook retries to 8, switch to Email, set retries to 1, switch back — both remembered. On S3, pick ap-northeast-1 with Glacier selected.
view source — publish-form.ts (29 lines)
import { branch, defineGraph } from '$lib/index.js';
import { enumOf } from '$lib/defs/index.js';
import { s3Graph, s3Meta } from './s3.js';
import { emailGraph, emailMeta } from './email.js';
import { webhookGraph, webhookMeta } from './webhook.js';

// The HUB: separately-defined destination graphs tied together by one
// discriminator FIELD, declared like any other field and dispatched by a
// keyed branch. The member keys type the arms, so
// `Extract<PublishState, { destination: 's3' }>` is exactly the s3 shape.

const DESTINATIONS = [s3Meta, emailMeta, webhookMeta];

const DESTINATION = enumOf({
  options: DESTINATIONS.map((d) => ({ value: d.key, label: d.label })),
  default: 's3',
});

export const publishForm = defineGraph()
  .field('destination', DESTINATION)
  .use(
    branch('destination', [
      [['s3'], s3Graph],
      [['email'], emailGraph],
      [['webhook'], webhookGraph],
    ] as const)
  );

export type PublishState = ReturnType<typeof publishForm.resolve>;
view source — s3.ts (37 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { enumOf } from '$lib/defs/index.js';

// One DESTINATION: a complete graph in its own module. Each field is one
// definition; the availability rule is one gate declaration whose condition
// reads the field above it.
export const s3Graph = defineGraph()
  .field('bucket', {
    input: z.string().optional(),
    output: z.string().regex(/^[a-z0-9.-]{3,63}$/, 'Lowercase letters, digits, dots, dashes'),
    default: '',
  })
  .field('region', enumOf({
    options: [
      { value: 'us-east-1', label: 'us-east-1' },
      { value: 'eu-central-1', label: 'eu-central-1' },
      { value: 'ap-northeast-1', label: 'ap-northeast-1' },
    ],
    default: 'us-east-1',
  }))
  .field('storageClass', (c) =>
    enumOf({
      options: [
        { value: 'standard', label: 'Standard' },
        { value: 'glacier', label: 'Glacier' },
      ],
      default: 'standard',
      gate: { glacier: c.region === 'ap-northeast-1' && 'class_unavailable_in_region' },
    })
  );

export const s3Meta = { key: 's3', label: 'S3' } as const;

// A destination is mountable ALONE — the graph and the standalone form are
// the same code. The hub is one consumer of it, not its owner.
export const s3Form = s3Graph;
view source — email.ts (34 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { boolOf, enumOf, slider } from '$lib/defs/index.js';

export const emailGraph = defineGraph()
  .field('recipients', {
    input: z.string().optional(),
    output: z
      .string()
      .min(1, 'At least one recipient')
      .refine(
        (s) => s.split(',').every((part) => z.string().email().safeParse(part.trim()).success),
        'Comma-separated email addresses'
      ),
    default: '',
  })
  .field('digest', boolOf())
  .field('digestFrequency', (c) =>
    c.digest
      ? enumOf({
          options: [
            { value: 'immediate', label: 'Immediate' },
            { value: 'daily', label: 'Daily digest' },
            { value: 'weekly', label: 'Weekly digest' },
          ],
          default: 'daily',
        })
      : null
  )
  // Same `retries` key as the webhook destination — scope keeps each
  // destination's remembered value separate.
  .field('retries', { ...slider({ min: 0, max: 10, default: 3 }), scope: 'email' });

export const emailMeta = { key: 'email', label: 'Email' } as const;
view source — webhook.ts (25 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { enumOf, slider } from '$lib/defs/index.js';

export const webhookGraph = defineGraph()
  .field('url', {
    input: z.string().optional(),
    output: z.string().url('A full https:// URL'),
    default: '',
  })
  .field('secret', {
    input: z.string().optional(),
    output: z.string().min(8, 'Signing secret needs 8+ characters'),
    default: '',
  })
  .field('method', enumOf({
    options: [
      { value: 'POST', label: 'POST' },
      { value: 'PUT', label: 'PUT' },
    ],
    default: 'POST',
  }))
  .field('retries', { ...slider({ min: 0, max: 10, default: 3 }), scope: 'webhook' });

export const webhookMeta = { key: 'webhook', label: 'Webhook' } as const;