# Metadata Engine & Multi-Tenant Schema

> The heart of Twenty's flexibility: the metadata engine that drives dynamic object definitions, field types, relations, and per-workspace GraphQL schema generation. Covers TwentyORM, workspace datasource, metadata modules (object-metadata, field-metadata, index-metadata), and the workspace cache layer.

- Repository: twentyhq/twenty
- GitHub: https://github.com/twentyhq/twenty
- Human wiki: https://grok-wiki.com/public/wiki/twentyhq-twenty-7ed82e5a21f6
- Complete Markdown: https://grok-wiki.com/public/wiki/twentyhq-twenty-7ed82e5a21f6/llms-full.txt

## Source Files

- `packages/twenty-server/src/engine/metadata-modules/object-metadata`
- `packages/twenty-server/src/engine/metadata-modules/field-metadata`
- `packages/twenty-server/src/engine/twenty-orm`
- `packages/twenty-server/src/engine/workspace-datasource`
- `packages/twenty-server/src/engine/workspace-cache`
- `packages/twenty-server/src/engine/workspace-cache-storage`
- `packages/twenty-server/src/engine/workspace-manager`
- `packages/twenty-server/src/engine/core-entity-cache`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts](packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts)
- [packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts](packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts)
- [packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts](packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts)
- [packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts](packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts)
- [packages/twenty-server/src/engine/workspace-datasource/utils/get-workspace-schema-name.util.ts](packages/twenty-server/src/engine/workspace-datasource/utils/get-workspace-schema-name.util.ts)
- [packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts](packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts)
- [packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts](packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts)
- [packages/twenty-server/src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service.ts](packages/twenty-server/src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service.ts)
- [packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory.ts](packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory.ts)
- [packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts](packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts)
- [packages/twenty-server/src/engine/workspace-manager/types/syncable-entity.interface.ts](packages/twenty-server/src/engine/workspace-manager/types/syncable-entity.interface.ts)
</details>

# Metadata Engine & Multi-Tenant Schema

Twenty's metadata engine is the core mechanism that allows each workspace to define its own object model at runtime — without requiring code changes or re-deployments. Every object type (e.g. `Company`, `Contact`, a custom `Deal`) is stored as a record in shared metadata tables, and its fields, relations, and indexes are likewise stored as metadata. The engine reads these records to dynamically generate PostgreSQL schemas, TypeORM `EntitySchema` definitions, and per-workspace GraphQL APIs on demand.

This page covers the full pipeline: from the canonical metadata entities stored in PostgreSQL, through the workspace datasource and TwentyORM layer that brings them alive as a queryable TypeORM data source, to the cache layer that keeps schema lookups sub-millisecond at runtime, and finally to the per-workspace GraphQL schema generation that exposes every object as a typed API.

---

## Multi-Tenant Schema Isolation

### Per-Workspace PostgreSQL Schema

Each workspace occupies its own PostgreSQL schema within the shared database. The schema name is derived deterministically from the workspace UUID:

```typescript
// packages/twenty-server/src/engine/workspace-datasource/utils/get-workspace-schema-name.util.ts
export const getWorkspaceSchemaName = (workspaceId: string): string => {
  return `workspace_${uuidToBase36(workspaceId)}`;
};
```

This produces stable, collision-free names like `workspace_3k2f9p1`. All workspace object tables live inside this schema; the core/metadata tables (users, workspaces, `objectMetadata`, `fieldMetadata`, etc.) remain in the default `public` schema.

### WorkspaceDataSourceService

`WorkspaceDataSourceService` manages the lifecycle of workspace schemas in PostgreSQL. It operates on the shared `coreDataSource` (TypeORM `DataSource` pointed at the main database) to create and drop schemas.

```typescript
// packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts:56-69
public async createWorkspaceDBSchema(workspaceId: string): Promise<string> {
  this.assertDDLNotLocked();
  const schemaName = getWorkspaceSchemaName(workspaceId);
  const queryRunner = this.coreDataSource.createQueryRunner();
  try {
    await queryRunner.createSchema(schemaName, true);
    return schemaName;
  } finally {
    await queryRunner.release();
  }
}
```

DDL changes can be globally locked via the `WORKSPACE_SCHEMA_DDL_LOCKED` config flag (used during hot upgrades), causing `createWorkspaceDBSchema` and `deleteWorkspaceDBSchema` to throw a `WorkspaceDataSourceException` with code `DDL_LOCKED`.

Sources: [packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts:30-38](packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts)

---

## Core Metadata Entities

All metadata is stored as rows in shared PostgreSQL tables. Three primary entities form the schema definition:

### ObjectMetadataEntity

`ObjectMetadataEntity` is a TypeORM entity (table `objectMetadata`) that represents a single object type within a workspace. It is scoped to a `workspaceId` via `SyncableEntity` and carries:

| Column | Description |
|---|---|
| `nameSingular` / `namePlural` | API names, unique per workspace |
| `labelSingular` / `labelPlural` | Display labels |
| `isCustom` | `true` for user-created types |
| `isSystem` | Hidden from API, used internally |
| `isRemote` | Object backed by an external data source |
| `isSearchable` | Included in global full-text search |
| `isAuditLogged` | Changes written to audit log |
| `labelIdentifierFieldMetadataId` | Field used as the primary display label |
| `standardOverrides` | JSONB blob for overriding standard object properties |
| `duplicateCriteria` | JSONB rules for deduplication |

Unique constraints enforce that `(nameSingular, workspaceId)` and `(namePlural, workspaceId)` are each unique.

Sources: [packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts:22-147](packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts)

### FieldMetadataEntity

`FieldMetadataEntity` (table `fieldMetadata`) represents one field on an object. It carries a generic type parameter `TFieldMetadataType` constraining the field's type to a value from the `FieldMetadataType` enum defined in `twenty-shared/types`.

Key columns:

| Column | Description |
|---|---|
| `type` | Field type (TEXT, NUMBER, RELATION, MORPH_RELATION, UUID, etc.) |
| `name` | Field name, unique per `(objectMetadataId, workspaceId)` |
| `defaultValue` | JSONB default, type-safe via `FieldMetadataDefaultValue<T>` |
| `options` | JSONB options (e.g. SELECT choices), type-safe via `FieldMetadataOptions<T>` |
| `settings` | JSONB settings (e.g. date format), type-safe via `FieldMetadataSettings<T>` |
| `relationTargetFieldMetadataId` | FK to the inverse side of a relation |
| `relationTargetObjectMetadataId` | Target object for relation fields |
| `morphId` | Required on `MORPH_RELATION` fields; references the morph discriminator |
| `isCustom` / `isSystem` / `isActive` | Lifecycle flags |
| `isUnique` | Derived at cache-build time from `IndexMetadata`; not stored as a column |
| `standardOverrides` | JSONB per-field label/description overrides |

A database-level `CHECK` constraint enforces that `MORPH_RELATION` fields must have a non-null `morphId`. The `isUnique` property is intentionally not persisted — it is derived during flat-entity cache construction from the presence of a single-field unique `IndexMetadata`, then attached to the flat representation for consumers.

Sources: [packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts:38-215](packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts)

### IndexMetadataEntity

`IndexMetadataEntity` (table `indexMetadata`) defines a database index on an object's underlying table. Each index references its parent `ObjectMetadataEntity` and carries one or more `IndexFieldMetadataEntity` join rows naming the fields to index.

Key properties: `isUnique` (unique constraint), `indexType` (`BTREE` by default, from the `IndexType` enum), and `indexWhereClause` for partial indexes.

Sources: [packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts:29-80](packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts)

### SyncableEntity — the Common Supertype

All three entities extend `SyncableEntity`, which in turn extends `WorkspaceRelatedEntity`. This base class carries the `workspaceId` foreign key that scopes each metadata record to a specific tenant, plus `universalIdentifier` and `applicationId` fields that support cross-workspace standard-object references and application-scoped objects.

---

## Flat-Entity Cache Layer

Querying raw metadata entities from the database on every API request would be prohibitively expensive. Twenty introduces a **flat-entity cache** that serializes the denormalized (joined) view of metadata into Redis and serves it with a simple version check.

### Cache Structure

The cache stores several parallel "flat maps" per workspace, each keyed on the metadata version number:

| Flat Map Key | Contents |
|---|---|
| `flatObjectMetadataMaps` | Object metadata indexed by id, by nameSingular, by namePlural |
| `flatFieldMetadataMaps` | Field metadata indexed by id, by object |
| `flatIndexMaps` | Index metadata indexed by id, by object |
| `flatApplicationMaps` | Application/integration scopes |

`FlatEntityMaps<T>` is a generic container that holds an `idByX` lookup map plus a flat `byId` record store, enabling O(1) access by any indexed key.

### WorkspaceCacheStorageService

`WorkspaceCacheStorageService` is the low-level cache access layer backed by Redis. It stores and retrieves the serialized flat maps and the workspace's `metadataVersion` integer. The version acts as the cache invalidation key.

### WorkspaceManyOrAllFlatEntityMapsCacheService

This service wraps `WorkspaceCacheStorageService` and adds a lazy-recompute strategy via `GetDataFromCacheWithRecomputeService`. On a cache miss it triggers a full recompute, writes results back to Redis, then serves the fresh data. Callers request only the keys they need (e.g. `['flatObjectMetadataMaps', 'flatFieldMetadataMaps']`), avoiding unnecessary deserialization.

```typescript
// packages/twenty-server/src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service.ts:23-93
getFromCacheWithRecompute = async ({ workspaceId, getCacheData,
  getCacheVersion, recomputeCache, ... }) => {
  cachedVersion = await getCacheVersion(workspaceId);
  if (isDefined(cachedVersion)) {
    const cacheKey = `${workspaceId}-${cachedVersion}`;
    const cachedValue = this.cache.get(cacheKey); // in-process Map
    if (cachedValue) return cachedValue;
  }
  cachedData = await getCacheData(workspaceId);
  if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
    await recomputeCache({ workspaceId }); // write to Redis
    ...
  }
  this.cache.set(cacheKey, { version, data }); // populate in-process map
  return { version, data };
};
```

There is a two-tier caching strategy: an in-process `Map` (keyed by `workspaceId-version`) sits in front of Redis. Cache hits in the in-process tier require no network round-trips at all.

Sources: [packages/twenty-server/src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service.ts:23-93](packages/twenty-server/src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service.ts)

---

## TwentyORM — Dynamic TypeORM Data Source

Standard TypeORM requires entity classes known at compile time. Twenty extends this with a dynamic `EntitySchema`-based approach through **TwentyORM**, allowing runtime objects (from `ObjectMetadataEntity` rows) to be queried via TypeORM in the correct workspace PostgreSQL schema.

### TwentyORMModule

`TwentyORMModule` is declared `@Global()` and bootstraps the ORM layer. It imports:
- `WorkspaceCacheStorageModule` — Redis-backed flat-map persistence
- `WorkspaceManyOrAllFlatEntityMapsCacheModule` — lazy-recompute cache service
- `WorkspaceCacheModule` — higher-level workspace cache (roles, permissions)
- `PermissionsModule`, `WorkspaceFeatureFlagsMapCacheModule`, `FeatureFlagModule`

It exports `EntitySchemaFactory`, which builds TypeORM `EntitySchema` instances from `FlatObjectMetadata` at request time, targeting the correct workspace schema name.

Sources: [packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts:1-36](packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts)

### EntitySchemaFactory

`EntitySchemaFactory` (exported from `TwentyORMModule`) translates a `FlatObjectMetadata` record — retrieved from the flat-entity cache — into a TypeORM `EntitySchema`. It sets the target PostgreSQL schema to `workspace_<base36(workspaceId)>`, maps each `FlatFieldMetadata` entry to a TypeORM column definition, and wires up relations. The result is a fully qualified TypeORM schema that can be used with a workspace-scoped `DataSource`.

---

## Per-Workspace GraphQL Schema Generation

Each authenticated request to the workspace GraphQL endpoint operates against a dynamically generated `GraphQLSchema` derived from the workspace's current metadata.

### WorkspaceGraphQLSchemaGenerator

`WorkspaceGraphQLSchemaGenerator` orchestrates GraphQL schema generation. It delegates to `GqlTypeGenerator`, which traverses the flat object/field metadata maps and builds all the types, queries, and mutations:

```typescript
// packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory.ts:18-52
async generateSchema(context: SchemaGenerationContext): Promise<GraphQLSchema> {
  const gqlTypesStorage = await this.gqlTypeGenerator.buildAndStore(context);

  const queryType = gqlTypesStorage.getGqlTypeByKey<GraphQLObjectType>(GqlOperation.Query);
  const mutationType = gqlTypesStorage.getGqlTypeByKey<GraphQLObjectType>(GqlOperation.Mutation);
  // throws if either is missing

  return new GraphQLSchema({
    query: queryType,
    mutation: mutationType,
    types: gqlTypesStorage.getAllGqlTypesExcept([GqlOperation.Query, GqlOperation.Mutation]),
  });
}
```

Sources: [packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory.ts:18-52](packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory.ts)

### WorkspaceGraphqlSchemaSDLService

This service wraps the schema generator with SDL (Schema Definition Language) caching. Its `getOrComputeSchemaSDL` method:
1. Checks that the workspace has a valid `databaseSchema` string.
2. Calls `WorkspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps` to load the four flat maps.
3. Optionally filters objects and fields to the scope of a specific `applicationId` (using `flatApplicationMaps`).
4. Calls `WorkspaceGraphQLSchemaGenerator.generateSchema` with a `SchemaGenerationContext` built from the filtered maps.
5. Returns the SDL string plus metadata about used scalars and the maps themselves.

```typescript
// packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts:23-29
export type WorkspaceGraphqlSchemaSDLResult = {
  sdl: string;
  usedScalarNames: string[];
  flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
  flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
};
```

Sources: [packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts:23-100](packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts)

---

## Data Flow: Request-Time Schema Resolution

```text
Authenticated API request
        │
        ▼
 AccessTokenService.validateTokenByRequest()
        │  → resolves workspace + metadataVersion
        ▼
 WorkspaceCacheStorageService.getMetadataVersion(workspaceId)
        │  → on miss: seeds version from workspace.metadataVersion
        ▼
 WorkspaceManyOrAllFlatEntityMapsCacheService
   .getOrRecomputeManyOrAllFlatEntityMaps(...)
        │  → in-process Map hit → return (no I/O)
        │  → Redis hit           → return (one network call)
        │  → miss                → recomputeCache() → Redis → return
        ▼
 FlatEntityMaps<FlatObjectMetadata>
 FlatEntityMaps<FlatFieldMetadata>
 FlatEntityMaps<FlatIndexMetadata>
        │
        ├─[GraphQL path]──▶ WorkspaceGraphQLSchemaGenerator.generateSchema()
        │                        → GqlTypeGenerator.buildAndStore(context)
        │                        → GraphQLSchema (query + mutation + types)
        │
        └─[REST path]────▶ EntitySchemaFactory (TwentyORM)
                                → TypeORM EntitySchema for workspace schema
                                → workspace DataSource query execution
```

---

## WorkspaceManagerModule — Workspace Lifecycle

`WorkspaceManagerModule` orchestrates workspace provisioning. It wires together:
- `WorkspaceDataSourceModule` — PostgreSQL schema creation/deletion
- `WorkspaceMigrationModule` — DDL migration runner
- `ObjectMetadataModule` — seeding standard objects
- `PermissionsModule`, `RoleModule`, `UserRoleModule` — default roles
- `TwentyStandardApplicationModule` — standard application objects

When a new workspace is created, `WorkspaceManagerService` calls `WorkspaceDataSourceService.createWorkspaceDBSchema`, seeds standard objects (via `ObjectMetadataModule`), runs DDL migrations to materialize tables in the new schema, and sets up default roles and permissions.

Sources: [packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts:25-49](packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts)

---

## Field Type System

Field types are defined as string literals in `twenty-shared/types` via `FieldMetadataType`. Representative types:

| Type | Storage | Notes |
|---|---|---|
| `TEXT` | `varchar` | Basic single-line text |
| `RICH_TEXT` | `jsonb` | Structured rich-text blocks |
| `NUMBER` | `float` | Numeric values |
| `BOOLEAN` | `boolean` | |
| `DATE_TIME` / `DATE` | `timestamptz` / `date` | |
| `SELECT` / `MULTI_SELECT` | `varchar` / `varchar[]` | Options stored in `fieldMetadata.options` JSONB |
| `RELATION` | — | FK resolved via `relationTargetFieldMetadataId` |
| `MORPH_RELATION` | — | Polymorphic relation; requires `morphId` |
| `UUID` | `uuid` | |
| `CURRENCY` | `jsonb` | Composite: amount + currency code |
| `ADDRESS` | `jsonb` | Composite: street, city, country, etc. |
| `LINKS` | `jsonb` | Composite: primary link + secondary links |
| `ACTOR` | `jsonb` | Composite: name + source + workspace member |

Composite ("composite") field types are stored as JSONB and their sub-fields are materialized as virtual fields during schema generation. The `isEnumFieldMetadataType` utility classifies SELECT/MULTI_SELECT for special GraphQL enum handling during schema build.

---

## Summary

Twenty's metadata engine achieves full per-workspace schema flexibility by storing object and field definitions as relational data, deriving PostgreSQL schemas dynamically using `workspace_<base36id>` namespacing, and keeping runtime overhead low through a two-tier (in-process + Redis) flat-entity cache. `TwentyORM` bridges the gap between dynamic metadata and TypeORM by generating `EntitySchema` objects at request time. The GraphQL layer reads the same flat maps to produce a complete, typed workspace API without static code generation. Any change to an object or field triggers a metadata version bump, invalidating the cache and causing the next request to recompute a fresh schema view.
