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
| Type | Description | Example Use |
|---|---|---|
text | Single- or multi-line text (set config.widget: 'textarea' for multi-line) | Titles, names, slugs, excerpts |
richtext | Translatable rich-text prose | Blog body, descriptions |
flexible | Ordered array of structured blocks | Long-form pages, marketing layouts |
number | Numeric values | Prices, quantities, priorities |
boolean | Yes/no toggle | Feature flags, visibility |
date | Date picker (can include time) | Publish dates, event dates |
enum | Inline {label, value} choices (defineEnumField) | Status, category, type |
select | Record-backed choice — options come from records of optionModelKey (defineSelectField) | Author, tag, related lookup table |
reference | Reference to another record | Author, related posts |
model | Embedded value of an inline model | Address, SEO block |
image | Image upload | Hero images, thumbnails |
video | Video upload (HLS auto-generated on paid plans) | Embedded videos |
file | Generic file upload | PDFs, documents |
list | Ordered list of items (itemType controls inner type) | Bullet points, tag arrays |
json | Raw JSON | Untyped 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
},
}| Option | Type | Description |
|---|---|---|
versioning | boolean | Keep a version history of every change |
publishing | boolean | Enable the draft → published workflow. Implies versioning. |
variants | boolean | Per-audience content variants. Implies versioning + publishing. |
inline | boolean | Expose 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'
},
}| Field | Value | Description |
|---|---|---|
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
| Property | Behaviour |
|---|---|
| Read only | A borrow confers read and nothing else — never write or delete on the referring record. |
| Opt-in per edge | Only a field declared association_read borrows. A plain association (the default) borrows nothing; the structural-only stance still holds for every other reference. |
| One hop | The 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. |
| Direction | The 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:
| Kind | Access conferred | Lifecycle |
|---|---|---|
association (default) | None — structural only | Target keeps its own access + lifecycle |
association_read | Borrows the referenced record’s read | Target keeps its own access + lifecycle |
composition | Parent 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_readmakes 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
- Configuration Reference — Full API reference
- Defining Operations — Add operations to process model data
- Defining Hooks — Trigger actions on model lifecycle events
- Field Types — Detailed field type documentation