Headlessly
Concepts

Relationships

Forward references, back-references, and cross-domain traversal in the 35-entity graph.

Arrow Syntax

Relationships between entities are declared using arrow syntax in the Noun definition. There are two directions:

SyntaxNameCardinalityMeaning
'-> Target.inverse'Forward referenceMany-to-oneThis entity points to one Target
'<- Target.inverse[]'Back-reference (collection)One-to-manyThis entity has many Targets
'<- Target.inverse'Back-reference (singular)One-to-oneThis entity has one Target

Forward References

A forward reference (->) means this entity holds a foreign key pointing to one instance of the target. The string after the dot names the inverse field on the target entity:

import { Noun } from 'digital-objects'

export const Contact = Noun('Contact', {
  name: 'string!',
  email: 'string?#',
  stage: 'Lead | Qualified | Customer | Churned | Partner',

  // Contact belongs to one Organization
  // "contacts" is the inverse field on Organization
  organization: '-> Organization.contacts',
})

At the database level, this creates an organization column on the Contact table that stores the Organization's $id. The .contacts suffix tells the system to create a back-reference on Organization named contacts.

Back-References

A back-reference (<-) means the target entity holds the foreign key. This entity does not store anything -- the relationship is computed by looking up all targets that point back:

export const Organization = Noun('Organization', {
  name: 'string!',
  domain: 'string?#',

  // Organization has many Contacts
  // The foreign key lives on Contact.organization
  contacts: '<- Contact.organization[]',
  deals: '<- Deal.organization[]',
})

The [] suffix indicates a collection. Without it, the back-reference is singular (one-to-one):

export const Customer = Noun('Customer', {
  // Customer has exactly one Contact (not a collection)
  contact: '<- Contact.customer',
})

Bidirectional Traversal

Every relationship is bidirectional. Declaring organization: '-> Organization.contacts' on Contact automatically makes contacts: '<- Contact.organization[]' available on Organization:

import { Contact } from '@headlessly/crm'
import { $ } from '@headlessly/sdk'

// Forward: Contact -> Organization
const contact = await Contact.get('contact_fX9bL5nRd', {
  include: ['organization']
})
console.log(contact.organization.name) // 'Acme Corp'

// Reverse: Organization -> Contacts
const org = await $.Organization.get('organization_k7TmPvQx', {
  include: ['contacts']
})
console.log(org.contacts.length) // 12

Cross-Domain Relationships

Relationships work across product domain boundaries. A single entity can reference entities from CRM, Billing, Support, and any other domain:

export const Contact = Noun('Contact', {
  name: 'string!',
  email: 'string?#',
  stage: 'Lead | Qualified | Customer | Churned | Partner',

  organization: '-> Organization.contacts',   // CRM -> CRM
  deals: '<- Deal.contact[]',                 // CRM <- CRM
  customer: '<- Customer.contact',            // CRM <- Billing
  tickets: '<- Ticket.contact[]',             // CRM <- Support
  messages: '<- Message.contact[]',           // CRM <- Communication
})

This is how headless.ly eliminates the integration layer. A Contact is not just a CRM record -- it is a node in a graph that connects to subscriptions, invoices, support tickets, and messages without any glue code.

import { $ } from '@headlessly/sdk'

// Traverse from CRM through Billing to Payment in one query
const contact = await $.Contact.get('contact_fX9bL5nRd', {
  include: ['deals', 'tickets', 'organization']
})

Indexed Fields

The # modifier on a data property creates an index for fast lookups. The ## suffix creates a unique index:

export const Contact = Noun('Contact', {
  name: 'string!',
  email: 'string?#',    // Indexed (fast lookups by email)
  phone: 'string?',     // Not indexed
})

export const User = Noun('User', {
  email: 'string!##',   // Unique indexed (no two users share an email)
})
ModifierEffectUse Case
#IndexedFields you query often (email, stage, status)
##Unique indexedFields that must be globally unique per tenant
(none)Not indexedFields rarely used in filters

Indexes are implemented as bloom filters and dictionary encoding in Parquet, enabling fast predicate pushdown without full scans.

Relationship Patterns

One-to-Many (Most Common)

A parent has many children. The child holds the foreign key:

// Project has many Issues
export const Project = Noun('Project', {
  name: 'string!',
  issues: '<- Issue.project[]',
})

export const Issue = Noun('Issue', {
  title: 'string!',
  project: '-> Project.issues',
})

Many-to-One

The inverse of one-to-many. Multiple entities point to one parent:

// Many Deals belong to one Pipeline
export const Deal = Noun('Deal', {
  name: 'string!',
  pipeline: '-> Pipeline.deals',
})

One-to-One

A singular back-reference without []:

// Each Customer maps to exactly one Contact
export const Customer = Noun('Customer', {
  contact: '<- Contact.customer',
})

Cross-Domain Chain

Traverse multiple domains in a single include:

import { $ } from '@headlessly/sdk'

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

// From billing: Subscription -> Plan -> Product
const sub = await $.Subscription.get('subscription_nT5xKpRm', {
  include: ['plan']
})

The Full Graph

All 35 entities connect through relationships. Key cross-domain edges:

FromRelationshipToDirection
ContactorganizationOrganization->
ContactdealsDeal<-[]
ContactticketsTicket<-[]
ContactmessagesMessage<-[]
CustomercontactContact<-
SubscriptionplanPlan->
SubscriptioncustomerCustomer->
InvoicesubscriptionSubscription->
DealcontactContact->
DealorganizationOrganization->
IssueprojectProject->
CommentissueIssue->
CampaignsegmentSegment->
WorkflowagentAgent->

Because every relationship is bidirectional, the graph can be traversed from any starting node to reach any connected node.

On this page