oCoreoCore Docs

Ent Schemas

Ent ORM usage in oCore, schema conventions, and database design patterns

oCore uses Ent as its ORM for type-safe, schema-first database access. Ent generates Go code from schema definitions, providing compile-time safety for all database operations. This page covers the conventions and patterns used across oCore's 83 schemas.

Overview

Ent schemas live in backend/internal/ent/schema/ and define the entire database structure. Running go generate ./internal/ent produces the generated client code in backend/internal/ent/.

backend/internal/ent/
├── schema/         # 83 hand-written schema definitions
├── client.go       # Generated client with type-safe CRUD operations
├── migrate/        # Generated migration support
└── ...             # Generated types, predicates, and query builders

Schema Conventions

UUID Primary Keys

All entities use UUID primary keys with uuid.New as the default value generator:

func (Server) Fields() []ent.Field {
    return []ent.Field{
        field.UUID("id", uuid.UUID{}).Default(uuid.New),
        // ...
    }
}

Using UUIDs instead of auto-increment integers prevents ID enumeration attacks and makes cross-system references predictable. The seed-e2e program uses deterministic UUIDs for test data.

Timestamps

Every entity includes created_at and updated_at fields:

field.Time("created_at").Default(time.Now).Immutable(),
field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now),
  • created_at is immutable (set once at creation)
  • updated_at automatically refreshes on every save via UpdateDefault

Soft Deletes

oCore does not use hard deletes for most entities. Instead, entities have a status field that transitions through lifecycle states:

field.Enum("status").
    Values("active", "removing", "deleted", "suspended").
    Default("active"),

The async delete pattern sets status to "removing", triggers a cleanup worker, and the worker handles actual teardown. See Job Queue for details.

Organization Scoping

Most entities are scoped to an organization via a required organization_id foreign key:

func (Server) Fields() []ent.Field {
    return []ent.Field{
        field.UUID("organization_id", uuid.UUID{}),
        // ...
    }
}

func (Server) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("organization", Organization.Type).
            Ref("servers").
            Field("organization_id").
            Required().
            Unique(),
    }
}

Ent privacy rules enforce that queries are always scoped to the current viewer's organization.

Indexes

Schemas define indexes for query performance and uniqueness constraints:

func (Server) Indexes() []ent.Index {
    return []ent.Index{
        // Composite index for org-scoped queries
        index.Fields("organization_id", "status"),
        // Unique constraint within an org
        index.Fields("organization_id", "name").Unique(),
    }
}

Common index patterns:

  • Organization + status: Most list queries filter by org and status
  • Unique within org: Names, slugs, and other identifiers are unique per organization, not globally
  • Foreign key indexes: On _id fields for join performance

Entity Categories

Core Entities

These are the primary domain objects users interact with:

SchemaDescriptionKey Fields
UserUser accountemail, name, password_hash, totp_enabled
OrganizationTenant/workspacename, slug, plan
ServerManaged servername, host, port, ssh_user, status
InstanceOdoo instancename, version, status, port_base
ProjectGit projectname, repository_url, branch
EnvironmentDeployment targetname, type (staging/production)
DeploymentDeploy recordstatus, started_at, completed_at

Supporting Entities

Configuration, relationships, and metadata:

SchemaDescription
OrgMemberUser membership in an organization
RoleRBAC role with permissions
DomainCustom domain mapping
DNSConfigDNS provider configuration
DatabaseConfigPostgreSQL settings per environment
BackupScheduleAutomated backup schedule
BackupDestinationS3/local backup storage target
AlertRuleMonitoring alert conditions
APIKeyAPI key for programmatic access
AuditLogAction audit trail

Operational Entities

Records of operations and metrics:

SchemaDescription
BackupBackup execution record
SnapshotFilesystem snapshot
DeploymentStepIndividual step in a deployment
DatabaseMetricCollected database performance metrics
EmailLogSent email record
BulkOperationBatch operation tracking
OperationLogGeneric operation audit

Edge (Relationship) Patterns

One-to-Many

The most common relationship pattern. The "many" side holds the foreign key:

// Organization has many Servers
// In Organization schema:
edge.To("servers", Server.Type)

// In Server schema:
edge.From("organization", Organization.Type).
    Ref("servers").
    Field("organization_id").
    Required().
    Unique()

Many-to-Many

Used for relationships like project-user access:

// Project has many Users with access (through ProjectAccess)
edge.To("project_accesses", ProjectAccess.Type)

Many-to-many relationships in oCore typically use an explicit join table (like ProjectAccess) rather than Ent's implicit M2M, allowing extra fields on the relationship.

Self-Referencing

Used for parent-child relationships:

// Environment can be cloned from another environment
edge.From("source_environment", Environment.Type).
    Ref("cloned_environments").
    Field("source_environment_id").
    Unique()

Privacy Rules

Ent privacy rules enforce multi-tenant isolation at the ORM level. The viewer context (set by the tenant middleware) ensures queries are automatically scoped:

// internal/viewer/viewer.go
type Viewer struct {
    UserID uuid.UUID
    OrgID  uuid.UUID
}

func New(userID, orgID uuid.UUID) Viewer {
    return Viewer{UserID: userID, OrgID: orgID}
}

Privacy rules in each schema check that the viewer's org matches the entity's org:

func (Server) Policy() ent.Policy {
    return privacy.Policy{
        Query: privacy.QueryPolicy{
            rule.FilterOrgRule(),
        },
        Mutation: privacy.MutationPolicy{
            rule.DenyMismatchedOrgRule(),
        },
    }
}

This means even if a handler accidentally passes a server ID from a different organization, the Ent query will return "not found" rather than leaking data.

Annotations

Schemas use Ent annotations for additional metadata:

func (Server) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entsql.Annotation{Table: "servers"},
    }
}

Working with Ent

Generating Code

After modifying schemas, regenerate the Ent client:

cd backend && go generate ./internal/ent

Creating Migrations

Ent manages database migrations. After schema changes:

cd backend && go run ./cmd/migrate/main.go

Query Examples

// Simple query with filter
servers, err := client.Server.Query().
    Where(server.OrganizationID(orgID), server.Status("active")).
    Order(ent.Desc(server.FieldCreatedAt)).
    All(ctx)

// Query with eager loading (edges)
env, err := client.Environment.Query().
    Where(environment.ID(envID)).
    WithProject().
    WithDatabaseConfig().
    Only(ctx)

// Aggregation
count, err := client.Server.Query().
    Where(server.OrganizationID(orgID)).
    Count(ctx)

Further Reading

Was this page helpful?