Skip to Content
Config SystemDefining Models

Defining Models

Models define the content types in your project. Each model has a key, a name, and a set of typed fields that describe what data it holds.

Model Interface

interface ApplyConfigModelInput { key: string; // Unique identifier (e.g., 'blog-post') name: string; // Display name (e.g., 'Blog Post') pluralName?: string; // Plural display name pluralKey?: string; // Plural key description?: string; // Model description fields?: FieldDefinitionInput[]; // Field definitions config?: Record<string, unknown>; // Additional model configuration access?: AccessInput; // Owner plane + default visibility (see Model Access) lookups?: LookupDefinitionInput[]; // Indexed access paths (see Lookups below) }

Basic Example

import { defineConfig, defineModel } from '@foir/cli/configs'; export default defineConfig({ key: 'my-blog', name: 'My Blog', models: [ defineModel({ key: 'blog-post', name: 'Blog Post', fields: [ { key: 'title', type: 'text', label: 'Title', required: true }, { key: 'slug', type: 'text', label: 'Slug', required: true }, { key: 'excerpt', type: 'text', label: 'Excerpt', config: { widget: 'textarea' } }, { key: 'body', type: 'richtext', label: 'Body' }, { key: 'publishDate', type: 'date', label: 'Publish Date' }, { key: 'featured', type: 'boolean', label: 'Featured' }, ], }), ], });

Field Definitions

Each field in a model is defined with the FieldDefinitionInput interface:

interface FieldDefinitionInput { key: string; // Unique field key within the model type: string; // Field type (see table below) label?: string; // Display label in the editor required?: boolean; // Whether the field must be filled queryable?: boolean; // Allow filtering/sorting on this field (see below) helpText?: string; // Guidance shown below the field placeholder?: string; // Placeholder text for input fields config?: Record<string, unknown>; // Type-specific configuration itemType?: string; // Item type for list fields storage?: string; // Storage configuration templateZone?: string; // Template zone assignment zoneOrder?: number; // Order within the template zone access?: { read?: string[]; write?: string[] }; // Field-level access control }

Using defineField

The defineField helper is useful when you want to reuse field definitions across multiple models:

import { defineConfig, defineField } from '@foir/cli/configs'; const seoTitle = defineField({ key: 'seoTitle', type: 'text', label: 'SEO Title', helpText: 'Appears in search engine results. Keep under 60 characters.', placeholder: 'Enter SEO title...', }); const seoDescription = defineField({ key: 'seoDescription', type: 'text', config: { widget: 'textarea' }, label: 'SEO Description', helpText: 'Appears in search engine results. Keep under 160 characters.', }); export default defineConfig({ key: 'my-site', name: 'My Site', models: [ { key: 'page', name: 'Page', fields: [seoTitle, seoDescription] }, { key: 'blog-post', name: 'Blog Post', fields: [seoTitle, seoDescription] }, ], });

Available Field Types

TypeDescriptionExample Use
textSingle- or multi-line text (set config.widget: 'textarea' for multi-line)Titles, names, slugs, excerpts
richtextTranslatable rich-text proseBlog body, descriptions
flexibleOrdered array of structured blocksLong-form pages, marketing layouts
numberNumeric valuesPrices, quantities, priorities
booleanYes/no toggleFeature flags, visibility
dateDate picker (can include time)Publish dates, event dates
enumInline {label, value} choices (defineEnumField)Status, category, type
selectRecord-backed choice — options come from records of optionModelKey (defineSelectField)Author, tag, related lookup table
referenceReference to another recordAuthor, related posts
modelEmbedded value of an inline modelAddress, SEO block
imageImage uploadHero images, thumbnails
videoVideo upload (HLS auto-generated on paid plans)Embedded videos
fileGeneric file uploadPDFs, documents
listOrdered list of items (itemType controls inner type)Bullet points, tag arrays
jsonRaw JSONUntyped payloads

richtext (translatable prose) and flexible (structured blocks) are distinct, separate types. See Field Types for full details on each type.

Enum Fields

enum is the inline-choice type: the options are declared in the config as an { label, value } array. Use defineEnumField so TypeScript enforces the shape.

import { defineEnumField } from '@foir/cli/configs'; defineEnumField({ key: 'status', type: 'enum', label: 'Status', required: true, config: { options: [ { value: 'draft', label: 'Draft' }, { value: 'review', label: 'In Review' }, { value: 'published', label: 'Published' }, { value: 'archived', label: 'Archived' }, ], multiple: false, // Allow selecting more than one value default: 'draft', // Default value (or array when multiple) }, })

Select Fields

select is record-backed: the options come from the records of another model, named via optionModelKey. Inline { label, value } options do not compile on a select field — use enum for those. Use defineSelectField for the typed config.

import { defineSelectField } from '@foir/cli/configs'; defineSelectField({ key: 'category', type: 'select', label: 'Category', required: true, config: { optionModelKey: 'category', // records of this model become the options multiple: false, // Allow selecting more than one record }, })

Lookups

lookups declares indexed access paths on a model so records can be fetched by a key other than the primary id. Each lookup names the ordered scalar field keys that make up the key via keyBy; composites are first-class.

defineModel({ key: 'redirect', name: 'Redirect', fields: [...], lookups: [ { keyBy: ['fromHost', 'fromPath'] }, // composite lookup { keyBy: ['slug'], name: 'redirectBySlug' }, // override generated query name ], })

keyBy lists top-level scalar fields only. name overrides the generated GraphQL query field name; omit it for the default <typeLowerCamel>By<PascalCaseKeyFields>. The platform validates existence, scalar type, top-level-only, and caps (4 lookups, 4 fields) at push time.

Queryable Fields

By default a field cannot be used in where/orderBy on records. Set queryable: true to register the field for the typed-filter side table so it can be filtered and sorted on. Without it, the platform rejects sorting by the field.

{ key: 'priority', type: 'number', label: 'Priority', queryable: true }

Field-Level Access Control

The optional access object names the principals allowed to read and write a field:

{ key: 'internalNote', type: 'text', label: 'Internal Note', access: { read: ['admin'], // exposed only to these principals via the client API write: ['admin', 'service'], // only these principals may write the field }, }

A non-empty write is an allow-list — any other principal is rejected by the platform on every write path. Principal values are "public", "self", "scoped", "service", and "admin". Omit access for the default (writable by any authenticated principal); read governs client API exposure only.

Model Config Options

The config object on a model carries its capability flags:

{ key: 'product', name: 'Product', fields: [...], config: { versioning: true, // Keep a version history of every change publishing: true, // Draft → published workflow (implies versioning) variants: true, // Per-audience content variants (implies publishing) inline: false, // Usable as a field type inside other models }, }
OptionTypeDescription
versioningbooleanKeep a version history of every change
publishingbooleanEnable the draft → published workflow. Implies versioning.
variantsbooleanPer-audience content variants. Implies versioning + publishing.
inlinebooleanExpose this model as a field type embeddable in other models, instead of a top-level public type

Every non-inline model is automatically queryable through the public API — there is no separate “expose” flag. inline models aren’t queryable on their own; they’re embedded as field values in other models.

Record ownership (who owns a model’s records, and their default visibility) is the separate top-level access field — see Model Access below.

Model Access

access declares which plane owns records of this model and what visibility new records start at — the broad layer of Foir’s unified resource-access model. It mirrors the admin UI’s model Owner plane control, and foir push reconciles it onto the model.

{ key: 'order', name: 'Order', fields: [...], access: { ownerPlane: 'customer', // 'customer' | 'admin' | 'project' defaultVisibility: 'private', // 'private' | 'public' }, }
FieldValueDescription
ownerPlane'customer'Records are owned by the customer who created them — per-customer isolation on the public API, private response cache, per-owner lookups. Required for a customer-context singleton.
'admin'Records are owned by the operator who created them; private to that operator unless explicitly shared.
'project'No individual owner; records belong to the project. The platform default.
defaultVisibility'private'New records are visible only to the owner and explicit grants. The default.
'public'New records are readable by anyone through the project’s public API, including unauthenticated public-key reads.

Omit access entirely to leave the model at the platform default (project-owned, private). Records’ per-record visibility and grants are managed at runtime from the admin editor’s Access tab.

Changing defaultVisibility later re-applies to a model’s existing records, in both directions: switch a model to public and its already-created records become readable; switch it back to private and they are hidden again. Records whose visibility you set individually from the Access tab are treated as deliberate and keep their setting, so a later change to the model default never overwrites them.

Relationship-derived visibility

By default a reference between records is structural only — pointing at a record never grants any access to it. (If it did, adding a reference would be a privilege-escalation path: anyone who could create a record pointing at yours could read it.) Sometimes, though, a record should be readable by whoever can read a record it points at. A reference field opts into that with the association_read relationship kind — relationship-derived read visibility, or a “borrow”.

Example

A customer owns their order (ownerPlane: 'customer', private). Back-office staff work a fulfilment_list that references the order. You want the owning customer to read the fulfilment list for their own order — without granting it explicitly each time, and without exposing anyone else’s. Declare the reference field’s relationship as association_read:

{ key: 'fulfilment_list', name: 'Fulfilment list', fields: [ { key: 'order', type: 'reference', config: { referenceTypes: ['order'], relationship: { kind: 'association_read' }, // borrow the order's read }, }, // ... ], }

Now a fulfilment_list record is readable by anyone who can read the order it references. The customer reads their own order → they read its fulfilment list. Another customer can’t read that order, so they can’t read its fulfilment list either.

What a borrow does — and doesn’t — grant

PropertyBehaviour
Read onlyA borrow confers read and nothing else — never write or delete on the referring record.
Opt-in per edgeOnly a field declared association_read borrows. A plain association (the default) borrows nothing; the structural-only stance still holds for every other reference.
One hopThe borrow does not chain. If A borrows from B and B borrows from C, being able to read C does not let you read A. Each declared edge grants exactly the one read it names.
DirectionThe referring record (the one holding the field) borrows from the referenced record. Reading the target grants reading the holder — not the reverse.

Relationship kinds

relationship.kind on a reference field selects how the edge behaves:

KindAccess conferredLifecycle
association (default)None — structural onlyTarget keeps its own access + lifecycle
association_readBorrows the referenced record’s readTarget keeps its own access + lifecycle
compositionParent access cascades to the child (read and write)Child is owned by the parent; deletes cascade

Why it’s opt-in. Relationships are structural by default precisely so that adding a reference can never silently widen who can see a record. association_read makes the access-bearing edge explicit and bounded: read-only, one hop, declared per field. Reach for it when the visibility genuinely derives from the relationship (a fulfilment list for an order, a line item on an invoice). For anything ad-hoc or one-off, issue an explicit per-record grant from the Access tab instead.

Real-World Example

From the Cloudflare KV Redirect extension — a redirect model with text, enum, number, and date fields:

{ key: 'redirect', name: 'Redirect', fields: [ { key: 'displayName', type: 'text', label: 'Display Name', required: false }, { key: 'sourcePattern', type: 'text', label: 'Source Pattern', required: true }, { key: 'targetPattern', type: 'text', label: 'Target Pattern', required: true }, { key: 'statusCode', type: 'enum', label: 'Status Code', required: true, config: { options: [ { value: '301', label: '301 - Permanent Redirect' }, { value: '302', label: '302 - Temporary Redirect' }, { value: '307', label: '307 - Temporary Redirect (preserve method)' }, { value: '308', label: '308 - Permanent Redirect (preserve method)' }, ], }, }, { key: 'priority', type: 'number', label: 'Priority', required: false }, { key: 'activeFrom', type: 'date', label: 'Active From', required: false }, { key: 'activeTo', type: 'date', label: 'Active Until', required: false }, { key: 'reason', type: 'enum', label: 'Reason', required: false, config: { options: [ { value: 'migration', label: 'Site Migration' }, { value: 'rebrand', label: 'Rebrand / Rename' }, { value: 'campaign', label: 'Marketing Campaign' }, { value: 'discontinued', label: 'Discontinued Content' }, { value: 'seo', label: 'SEO Optimization' }, { value: 'other', label: 'Other' }, ], }, }, ], }

Next Steps

Last updated on