Schema Patterns
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
})
}
}
Multi-Tool Apps
Patterns for building multi-tool applications with protokit — shared Y.js documents, workspace-scoped stores, staged navigation, and the produces/consumes data pipeline.
Custom Fields & Viz
Add custom field components and visualization types to nuxt-protokit without modifying the module source, using defineProtokitExtension and the protokit:register-extension hook.