Schema Patterns

Real-world schema patterns — multi-threshold badges, null-safe connections, collection-driven charts, linked question responses, tab layouts with badges, and action toolbars.

Schema Patterns

A cookbook of advanced patterns for real-world tool schemas.

Pattern: Multi-threshold result badge

Use tiers to guide users toward a specific goal range:

results: {
  badge: ({ derived }) => {
    const ratio = derived.coverageRatio
    if (ratio === null) return null
    if (ratio >= 5)    return { label: `${ratio.toFixed(1)}x — Excellent`, color: 'success' }
    if (ratio >= 3)    return { label: `${ratio.toFixed(1)}x — Healthy`,   color: 'primary' }
    if (ratio >= 1.5)  return { label: `${ratio.toFixed(1)}x — Marginal`,  color: 'warning' }
    return                   { label: `${ratio.toFixed(1)}x — Critical`,   color: 'error' }
  },
}

Returning null hides the badge entirely — useful when there is not enough data to form a verdict.

Pattern: Null-safe connection with local fallback

Always guard against missing upstream data. Provide a local input field as a fallback so the tool remains useful standalone:

fields: {
  // Used when no upstream tool is connected
  localMonthlyRevenue: {
    type: 'number',
    label: 'Monthly Revenue (€)',
    help: 'Auto-filled from Revenue tool when linked',
  },
},

consumes: {
  upstreamRevenue: 'revenue-tool.monthlyRevenue',
},

derived: {
  effectiveRevenue: {
    compute: ({ fields, connections }) =>
      connections.upstreamRevenue ?? fields.localMonthlyRevenue ?? 0,
  },
  margin: {
    compute: ({ fields, derived }) => {
      if (!derived.effectiveRevenue) return null
      return (derived.effectiveRevenue - fields.monthlyCosts) / derived.effectiveRevenue
    },
    format: { type: 'percent' },
  },
}

Pattern: Collection-driven bar chart

Generate visualization data dynamically from CRUD items:

collections: {
  categories: {
    label: 'Budget Categories',
    fields: {
      name:   { type: 'text',   label: 'Category' },
      amount: { type: 'number', label: 'Monthly Amount (€)' },
    },
  },
},

derived: {
  totalBudget: {
    compute: ({ collections }) =>
      (collections.categories ?? []).reduce((sum, c) => sum + (c.amount ?? 0), 0),
    format: { type: 'money', currency: '' },
  },
},

visualizations: [
  {
    type: 'bar-chart',
    label: 'Budget by Category',
    orientation: 'horizontal',
    items: ({ collections }) =>
      (collections.categories ?? [])
        .sort((a, b) => (b.amount ?? 0) - (a.amount ?? 0))
        .map(c => ({ label: c.name, value: c.amount ?? 0 })),
    format: 'money',
  },
],

Pattern: Linked question-response collection

For tools where you have a list of questions and a list of sessions/records — with one answer per question inside each session:

collections: {
  questions: {
    label: 'Questions',
    fields: {
      text:     { type: 'text',   label: 'Question Text', required: true },
      category: { type: 'select', label: 'Category',
                  options: [
                    { value: 'open',   label: 'Open-ended' },
                    { value: 'rating', label: 'Rating' },
                    { value: 'yn',     label: 'Yes/No' },
                  ] },
    },
    presets: [
      {
        id: 'discovery-pack',
        label: 'Discovery Questions',
        description: '6 open-ended questions for a first session',
        items: [
          { text: 'What is the biggest challenge you face with X?', category: 'open' },
          { text: 'How are you handling X today?',                  category: 'open' },
          { text: 'What have you tried that did not work?',         category: 'open' },
        ],
      },
    ],
  },

  sessions: {
    label: 'Sessions',
    singularLabel: 'Session',
    fields: {
      participant:  { type: 'text',   label: 'Participant Name' },
      date:         { type: 'date',   label: 'Date' },
      overallRating: {
        type: 'segmented',
        label: 'Overall Rating',
        options: [
          { value: 'negative', label: '👎' },
          { value: 'neutral',  label: '😐' },
          { value: 'positive', label: '👍' },
        ],
      },
      tags:          { type: 'tags',    label: 'Key Themes' },
      // One textarea per question from the 'questions' collection
      responses: {
        type: 'linked-responses',
        label: 'Question Responses',
        sourceCollection: 'questions',
        questionField: 'text',
      },
      keyTakeaway:   { type: 'textarea', label: 'Key Takeaway', colSpan: 2 },
    },
    editMode:  'modal',
    modalSize: '3xl',
  },
},

derived: {
  sessionCount:    { compute: ({ collections }) => collections.sessions?.length ?? 0 },
  allThemes: {
    compute: ({ collections }) =>
      [...new Set((collections.sessions ?? []).flatMap(s => s.tags ?? []))],
  },
  positiveCount: {
    compute: ({ collections }) =>
      (collections.sessions ?? []).filter(s => s.overallRating === 'positive').length,
  },
},

produces: {
  sessionCount:  'derived.sessionCount',
  themes:        'derived.allThemes',
  positiveRate:  'derived.positiveRate',
},

Pattern: Tab layout with live badges

Show real-time item counts in tab labels:

layout: {
  tabs: [
    {
      id: 'questions',
      label: 'Questions',
      badge: ({ collections }) => collections.questions?.length || undefined,
      rows: [
        { cols: [{ type: 'collection', collectionKey: 'questions', span: 12 }] },
      ],
    },
    {
      id: 'sessions',
      label: 'Sessions',
      badge: ({ collections }) => collections.sessions?.length || undefined,
      rows: [
        { cols: [{ type: 'collection', collectionKey: 'sessions', span: 12 }] },
      ],
    },
    {
      id: 'insights',
      label: 'Insights',
      rows: [
        { cols: [
          { type: 'stats', span: 12 },
          { type: 'viz',   span: 12 },
        ]},
      ],
    },
  ],
}

Pattern: Export and copy actions

actions: [
  {
    type: 'copy-text',
    label: 'Copy Question List',
    icon: 'i-lucide-clipboard-list',
    text: ({ collections }) =>
      (collections.questions ?? [])
        .map((q, i) => `${i + 1}. ${q.text}`)
        .join('\n\n'),
  },
  {
    type: 'export-markdown',
    label: 'Export Full Report',
    icon: 'i-lucide-file-text',
    markdown: ({ derived, collections }) => [
      `# Session Report`,
      ``,
      `**${derived.sessionCount} sessions completed**`,
      ``,
      `## Recurring Themes`,
      ...(derived.allThemes ?? []).map((t: string) => `- ${t}`),
      ``,
      `## Session Notes`,
      ...(collections.sessions ?? []).map((s: any) =>
        `### ${s.participant}${s.date}\n${s.keyTakeaway ?? '_No takeaway recorded._'}`
      ),
    ].join('\n'),
  },
  {
    type: 'reset',
    label: 'Reset All Data',
    icon: 'i-lucide-trash-2',
    confirm: true,
  },
]

Pattern: Conditional field visibility

Hide fields that are not relevant given the current state:

fields: {
  mode: {
    type: 'segmented',
    label: 'Mode',
    options: [
      { value: 'simple',   label: 'Simple' },
      { value: 'advanced', label: 'Advanced' },
    ],
    default: 'simple',
  },
  basicInput: {
    type: 'number',
    label: 'Basic Input',
  },
  advancedInputA: {
    type: 'number',
    label: 'Factor A',
    hidden: ({ fields }) => fields.mode !== 'advanced',
  },
  advancedInputB: {
    type: 'number',
    label: 'Factor B',
    hidden: ({ fields }) => fields.mode !== 'advanced',
  },
},

Pattern: Schema versioning

When you need to break compatibility with stored data (rename fields, change types), use a version suffix:

// v1 — original
const estimatorV1 = definePrototype({ key: 'cost-estimator',})

// v2 — breaking change (field renamed, new derived values)
// New key = fresh IndexedDB store — users get a clean slate
const estimatorV2 = definePrototype({ key: 'cost-estimator-v2',})

If you need to migrate data from v1 to v2, do it in a one-time migration function:

// In a plugin or onMounted hook
async function migrateV1toV2(workspaceDoc: Y.Doc) {
  const v1Fields = workspaceDoc.getMap('fields:cost-estimator')
  const v2Fields = workspaceDoc.getMap('fields:cost-estimator-v2')

  if (v1Fields.size > 0 && v2Fields.size === 0) {
    workspaceDoc.transact(() => {
      // Rename: oldName → newName
      v2Fields.set('newFieldName', v1Fields.get('oldFieldName'))
      // … copy remaining fields
    })
  }
}

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.