Headlessly
Concepts

Digital Objects

Complete reference for the Noun() function, property patterns, type modifiers, relationships, and entity composition.

Noun() Function

The Noun() function is the sole primitive for defining entities. It produces a typed, versioned, event-sourced Digital Object with full CRUD, custom verbs, relationships, and TypeScript inference.

import { Noun } from 'digital-objects'

export const Contact = Noun('Contact', {
  // Data properties
  name: 'string!',
  email: 'string?#',
  phone: 'string?',
  title: 'string?',

  // Enum property
  stage: 'Lead | Qualified | Customer | Churned | Partner',

  // Relationships
  organization: '-> Organization.contacts',
  deals: '<- Deal.contact[]',
  tickets: '<- Ticket.contact[]',
  messages: '<- Message.contact[]',

  // Custom verbs
  qualify:  'Qualified',
  capture:  'Captured',
  assign:   'Assigned',
  merge:    'Merged',
  enrich:   'Enriched',
})

The first argument is the entity name (PascalCase). The second is a property map where each value is a type-description string that the parser interprets based on its shape.

Property Value Patterns

Every value in a Noun definition is a string (or null) that the parser classifies by pattern:

PatternClassificationDetection RuleExample
Lowercase type with modifiersData propertyStarts with a known base type'string!', 'number?', 'datetime!#'
Arrow syntaxRelationshipStarts with -> or <-'-> Organization.contacts'
Pipe-separated PascalCaseEnumContains | with PascalCase values'Lead | Qualified | Customer'
Single PascalCase wordVerb declarationOne PascalCase token, no pipes'Qualified', 'Captured'
nullCRUD opt-outLiteral nullupdate: null

Base Types

TypeDescriptionExample Value
stringUTF-8 text'Alice Chen'
numberIEEE 754 float42, 3.14
booleanTrue/falsetrue
datetimeISO 8601 timestamp'2026-01-15T12:00:00Z'
dateISO 8601 date'2026-01-15'
jsonArbitrary JSON value{ tags: ['vip'] }
urlValid URL string'https://example.com'
emailValid email string'alice@example.com'
idEntity reference ID'contact_fX9bL5nRd'

Type Modifiers

Modifiers are appended directly to the base type with no spaces:

ModifierMeaningParquet EffectExample
!RequiredNOT NULL constraint'string!'
?OptionalNullable'string?'
#IndexedBloom filter + dictionary encoding'string?#'
!#Required + IndexedNOT NULL + indexed'string!#'
?#Optional + IndexedNullable + indexed'string?#'

Every property must declare either ! (required) or ? (optional). The # index modifier can be combined with either.

Relationships

Relationships use arrow syntax to declare typed, bidirectional links between entities.

Forward Reference (->)

Many-to-one. The current entity holds a foreign key pointing to one instance of the target entity.

// Contact belongs to one Organization
organization: '-> Organization.contacts'

The string after the dot (contacts) names the inverse field on the target entity, enabling bidirectional traversal.

Reverse Reference (<-)

One-to-many (or one-to-one without []). The target entity holds the foreign key.

// Contact has many Deals
deals: '<- Deal.contact[]'

// Customer has one Contact (no [] suffix)
contact: '<- Contact.customer'

Collection Modifier ([])

Appended to the inverse field name to indicate a collection (array) relationship:

SyntaxCardinality
'-> Target.inverse'Many-to-one (this entity has one Target)
'<- Target.inverse[]'One-to-many (this entity has many Targets)
'<- Target.inverse'One-to-one reverse

Cross-Domain Relationships

Relationships work across product domains. A Contact (CRM) can reference a Customer (Billing) and Tickets (Support):

export const Contact = Noun('Contact', {
  organization: '-> Organization.contacts', // CRM -> CRM
  customer: '<- Customer.contact',       // CRM <- Billing
  tickets: '<- Ticket.contact[]',        // CRM <- Support
  deals: '<- Deal.contact[]',           // CRM <- CRM
})

Enum Properties

Pipe-separated PascalCase values define a closed set:

stage: 'Lead | Qualified | Customer | Churned | Partner'
priority: 'Low | Medium | High | Critical'
status: 'Open | In Progress | Resolved | Closed'

Enums are stored as dictionary-encoded strings in Parquet and enforced at write time. Attempting to set an invalid value throws a validation error.

Custom Verb Declaration

A property whose value is a single PascalCase word declares a custom verb. The key is the verb infinitive; the value is the past-tense event name:

export const Deal = Noun('Deal', {
  name: 'string!',
  value: 'number!',
  stage: 'Discovery | Proposal | Negotiation | Closed | Lost',
  contact: '-> Contact.deals',
  organization: '-> Organization.deals',

  advance: 'Advanced',   // Deal.advance() emits Deal.Advanced
  close:   'Closed',     // Deal.close()   emits Deal.Closed
  lose:    'Lost',       // Deal.lose()    emits Deal.Lost
  reopen:  'Reopened',   // Deal.reopen()  emits Deal.Reopened
})

Every custom verb receives the full conjugation lifecycle. See the Verbs reference for details.

CRUD Opt-Out

Set a CRUD verb key to null to remove it from the entity:

export const Event = Noun('Event', {
  name: 'string!',
  type: 'string!',
  data: 'json?',
  timestamp: 'datetime!',
  actor: 'id!',
  target: 'id?',

  update: null,   // Events are append-only
  delete: null,   // Events are never deleted
})

With update: null, calling Event.update() is a TypeScript compile error. The CRUD verb and its conjugation (updating, updated, updatedBy) are all removed.

CRUD KeyDefaultEffect of null
createEnabled on all NounsCannot create (rarely used)
updateEnabled on all NounsImmutable after creation
deleteEnabled on all NounsCannot delete (soft-delete only)

Entity ID Format

Every entity instance receives an auto-generated ID in the format {type}_{sqid}:

import { Contact } from '@headlessly/crm'

const contact = await Contact.create({ name: 'Alice', stage: 'Lead' })
console.log(contact.$id) // 'contact_fX9bL5nRd'

IDs are generated by sqids -- short, unique, URL-safe, with a built-in blocklist. The type prefix enables type-safe ID parsing across the system.

The 35-Entity Graph

All 35 core entities compose into a single connected graph via relationships:

DomainEntitiesKey Relationships
IdentityUser, ApiKeyApiKey -> User
CRMOrganization, Contact, Lead, Deal, Activity, PipelineContact -> Organization, Deal -> Contact
BillingCustomer, Product, Plan, Price, Subscription, Invoice, PaymentSubscription -> Plan, Invoice -> Subscription
ProjectsProject, Issue, CommentIssue -> Project, Comment -> Issue
ContentContent, Asset, SiteContent -> Site, Asset -> Content
SupportTicketTicket -> Contact
AnalyticsEvent, Metric, Funnel, GoalEvent -> Funnel, Metric -> Goal
MarketingCampaign, Segment, FormCampaign -> Segment, Form -> Campaign
ExperimentationExperiment, FeatureFlagExperiment -> FeatureFlag
PlatformWorkflow, Integration, AgentWorkflow -> Agent, Integration -> Workflow
CommunicationMessageMessage -> Contact

Because relationships are bidirectional, traversal works in any direction:

import { $ } from '@headlessly/sdk'

const contact = await $.Contact.get('contact_fX9bL5nRd', {
  include: ['deals', 'tickets', 'organization']
})
// contact.deals        -> Deal[]
// contact.tickets      -> Ticket[]
// contact.organization -> Organization

SDK Usage

import { Contact } from '@headlessly/crm'

// Create
const contact = await Contact.create({ name: 'Alice', stage: 'Lead' })

// Read
const found = await Contact.find({ stage: 'Lead' })
const one = await Contact.get('contact_fX9bL5nRd')

// Update
await Contact.update('contact_fX9bL5nRd', { stage: 'Qualified' })

// Delete
await Contact.delete('contact_fX9bL5nRd')

// Custom verb
await Contact.qualify({ id: 'contact_fX9bL5nRd' })
headless.ly/mcp#fetch
{ "type": "Contact", "id": "contact_fX9bL5nRd", "include": ["deals", "organization"] }

On this page