Collections

Collections model CRUD lists of structured items. They map to Y.Arrays for offline-capable persistence with search, sort, modal/inline editing, and preset packs.

Collections

A collection is a schema-driven CRUD list. Each collection maps to a Y.Array in the Y.js document, so items persist offline and sync automatically when connectivity is restored.

Defining a collection

Collections live in the top-level collections key of a PrototypeSchema:

collections: {
  entries: {                          // collection key — also the Y.Array name
    label: 'Entries',
    singularLabel: 'Entry',           // used in "Add Entry" button text
    icon: 'i-lucide-list',

    fields: {
      name:     { type: 'text',      label: 'Name',     required: true },
      category: {
        type: 'select',
        label: 'Category',
        options: [
          { value: 'a', label: 'Type A' },
          { value: 'b', label: 'Type B' },
        ],
        default: 'a',
      },
      score:    { type: 'range',     label: 'Score',   min: 1, max: 10, default: 5 },
      notes:    { type: 'textarea',  label: 'Notes' },
    },

    editMode:  'modal',      // 'inline' (default) | 'modal'
    modalSize: 'xl',         // 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'

    searchable:   true,
    sortable:     true,
    tableColumns: [
      { key: 'name',     label: 'Name' },
      { key: 'category', label: 'Category' },
      { key: 'score',    label: 'Score', sortable: true },
    ],

    maxItems: 100,
  }
}

Edit modes

inline (default)

Fields render inline in the list row. Good for collections with 1–3 short fields where you want quick in-place editing.

Each item opens in a ProtoCrudModal. Recommended for collections with many fields, long textareas, or complex nested data. The modal automatically persists drafts — if a user closes without saving, their work is preserved in Y.Map 'draft:{key}' and offered for resumption on next open.

editMode:  'modal',
modalSize: '3xl',   // wider sizes auto-enable 2-column field layout

Preset packs

Preset packs let users bootstrap a collection with starter items. They appear on the empty state of the collection as clickable cards.

presets: [
  {
    id: 'starter-pack',
    label: 'Starter Pack',
    icon: 'i-lucide-package',
    description: '5 commonly used entries to get you started',
    items: [
      { name: 'Example Alpha',   category: 'a', score: 7 },
      { name: 'Example Beta',    category: 'b', score: 5 },
      { name: 'Example Gamma',   category: 'a', score: 8 },
    ],
  },
  {
    id: 'detailed-pack',
    label: 'Detailed Template',
    icon: 'i-lucide-layout-template',
    description: '10 entries with pre-filled notes',
    items: [
      // …
    ],
  },
]

Clicking a preset card populates the collection with those items. The user can then edit, add to, or remove them.

Onboarding presets

A variant displayed on the very first empty state — before the user has ever added an item:

onboardingPresets: [
  {
    id: 'quick-start',
    label: 'Quick Start',
    description: 'Get up and running in 30 seconds',
    items: [...],
  },
]

Once items exist, the collection switches to the compact list view with a normal "Add" button.

Using collection data in derived

Collection items are available in ComputeContext.collections:

derived: {
  totalItems: {
    compute: ({ collections }) => collections.entries?.length ?? 0,
  },
  avgScore: {
    compute: ({ collections }) => {
      const items = collections.entries ?? []
      if (!items.length) return null
      return items.reduce((sum, i) => sum + (i.score ?? 0), 0) / items.length
    },
    format: { type: 'number', decimals: 1 },
  },
  topEntries: {
    compute: ({ collections }) =>
      (collections.entries ?? [])
        .filter(i => i.score >= 8)
        .map(i => i.name),
  },
}

Schema migration

Adding a new field to a collection requires no migration — existing items are automatically merged with defaults on read, so the new field gets its default value transparently.

Renaming a field, changing its type, or transforming values requires bumping version and providing a migrations map. Each key is the target version — so if stored data is at v2 and current is v5, the runner applies migrations[3], migrations[4], migrations[5] in order. Steps with no entry are skipped.

collections: {
  tasks: {
    version: 2,
    migrations: {
      // v0 → v1: renamed done:boolean to status:string (destructure out the old field)
      1: ({ done, ...rest }) => ({
        ...rest,
        status: done ? 'done' : 'open',
      }),
      // v1 → v2: split assignee:string into assigneeId + assigneeName (remove old field)
      2: ({ assignee, ...rest }) => ({
        ...rest,
        assigneeId:   assignee?.toLowerCase().replace(/\s+/g, '-') ?? '',
        assigneeName: assignee ?? '',
      }),
    },
    fields: {
      status:      { type: 'select', label: 'Status', default: 'open', options: ['open', 'done'] },
      assigneeId:  { type: 'text',   label: 'Assignee ID',   default: '' },
      assigneeName:{ type: 'text',   label: 'Assignee Name', default: '' },
    },
    defaults: { status: 'open', assigneeId: '', assigneeName: '' },
  },
}

Each step function receives the raw stored item — exactly the fields that were written to Y.js at that version, with no defaults pre-applied. Destructure out the old field name and spread the rest to keep the shape clean. Migrations run once after the persistence layer has loaded stored data, rewrite all items in Y.js in a single transaction, and are skipped on every subsequent load.

Validation

Add a validate function to a collection schema to block saves when an item is invalid. Return true to allow saving, a string for a top-level error message, or a Record<string, string> to highlight specific fields red with per-field messages.

collections: {
  entries: {
    fields: { ... },
    // Field-level errors — each key matches a field name
    validate: (item) => {
      const errors: Record<string, string> = {}
      if (!item.name?.trim()) errors.name = 'Name is required.'
      if (item.score < 1 || item.score > 10) errors.score = 'Score must be between 1 and 10.'
      return Object.keys(errors).length ? errors : true
    },
  },
}

When a Record<string, string> is returned, each named field turns red with its error message displayed below it. A plain string return shows a single banner error instead. Saving is blocked until validate returns true.

Exposing collection aggregates via produces

produces: {
  avgScore:   'derived.avgScore',
  itemCount:  'derived.totalItems',
  topEntries: 'derived.topEntries',
}

Downstream tools can then consume these values directly without access to the raw collection.

CollectionSchema full type

interface CollectionSchema {
  label:              string
  singularLabel?:     string
  icon?:              string
  fields:             Record<string, FieldDef>
  defaults?:          Record<string, any>
  editMode?:          'inline' | 'modal'
  modalSize?:         'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'
  searchable?:        boolean
  sortable?:          boolean
  tableColumns?:      { key: string; label: string; sortable?: boolean }[]
  maxItems?:          number
  presets?:           PresetPack[]
  onboardingPresets?: PresetPack[]
  validate?:          (item: Record<string, any>) => true | false | string | Record<string, string>
  version?:           number
  migrations?:        Record<number, (item: Record<string, any>) => Record<string, any>>
}

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.