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 buildersSchema 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_atis immutable (set once at creation)updated_atautomatically refreshes on every save viaUpdateDefault
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
_idfields for join performance
Entity Categories
Core Entities
These are the primary domain objects users interact with:
| Schema | Description | Key Fields |
|---|---|---|
User | User account | email, name, password_hash, totp_enabled |
Organization | Tenant/workspace | name, slug, plan |
Server | Managed server | name, host, port, ssh_user, status |
Instance | Odoo instance | name, version, status, port_base |
Project | Git project | name, repository_url, branch |
Environment | Deployment target | name, type (staging/production) |
Deployment | Deploy record | status, started_at, completed_at |
Supporting Entities
Configuration, relationships, and metadata:
| Schema | Description |
|---|---|
OrgMember | User membership in an organization |
Role | RBAC role with permissions |
Domain | Custom domain mapping |
DNSConfig | DNS provider configuration |
DatabaseConfig | PostgreSQL settings per environment |
BackupSchedule | Automated backup schedule |
BackupDestination | S3/local backup storage target |
AlertRule | Monitoring alert conditions |
APIKey | API key for programmatic access |
AuditLog | Action audit trail |
Operational Entities
Records of operations and metrics:
| Schema | Description |
|---|---|
Backup | Backup execution record |
Snapshot | Filesystem snapshot |
DeploymentStep | Individual step in a deployment |
DatabaseMetric | Collected database performance metrics |
EmailLog | Sent email record |
BulkOperation | Batch operation tracking |
OperationLog | Generic 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/entCreating Migrations
Ent manages database migrations. After schema changes:
cd backend && go run ./cmd/migrate/main.goQuery 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
- Service Layer -- How services use Ent
- Backend Development -- Adding new schemas step-by-step
- Testing Guide -- Test helpers for Ent entities