Split of #20377.
## Summary
This PR separates available permission flags from per-role permission
flag grants.
Previously, `core.permissionFlag` stored the role assignment directly:
`roleId + flag`. This PR renames that legacy grant table to
`core.rolePermissionFlag`, then recreates `core.permissionFlag` as the
catalog of available permission flags.
## What changed
- Rename the existing `core.permissionFlag` grant table to
`core.rolePermissionFlag`.
- Add the new syncable `core.permissionFlag` catalog entity with key,
label, description, icon, permission type, relevance flags, and
custom/standard metadata.
- Add stable `SystemPermissionFlag` universal identifiers for the
built-in `PermissionFlagType` values.
- Seed the standard permission flags for every workspace under the
Twenty standard application.
- Backfill existing role grants:
- create missing catalog rows for existing grant keys,
- add `rolePermissionFlag.permissionFlagId`,
- migrate grants from the old string `flag` column to the new catalog
FK,
- replace the old `(flag, roleId)` uniqueness with `(permissionFlagId,
roleId)`.
- Rewire role permission flag caches, permission checks, role DTO
mapping, and `upsertPermissionFlags` to resolve through the catalog.
- Keep the existing public role permission API shape: product/app
surfaces still talk about `permissionFlags` and return `{ id, roleId,
flag }`.
- Update metadata flat-entity machinery, migration builders, validators,
action handlers, snapshots, generated schemas, docs, and app fixtures
for the new `permissionFlag` / `rolePermissionFlag` split.
## Behavior after this PR
- Existing permission flag grants keep working.
- Existing GraphQL role permission flows keep the same public naming.
- Standard permission flags are represented as catalog rows.
- Permission checks now compare grants through catalog universal
identifiers instead of the legacy `flag` column.
- Workspace deletion cleanup now verifies both `permissionFlag` and
`rolePermissionFlag`.
## What is not in this PR
- Public GraphQL CRUD for custom permission flags.
- App manifest support for declaring new custom permission flags.
- Frontend UI for creating or assigning custom permission flags beyond
the existing role permission flow.
---------
Co-authored-by: Weiko <corentin@twenty.com>
## Summary
- Converts applicationVariable from a bespoke sync path to a proper
SyncableEntity,
unifying it with the workspace migration pipeline used by all other
manifest-managed
entities (agent, skill, frontComponent, webhook, etc.)
- Removes the upsertManyApplicationVariableEntities method and its
direct-DB-mutation
approach in favor of the standard validate → build → run action handler
pipeline
- Adds universalIdentifier, deletedAt columns and makes applicationId
NOT NULL via an
instance command migration
## Motivation
Before this change, applicationVariable was the only manifest-managed
entity that bypassed
ApplicationManifestMigrationService.syncMetadataFromManifest(). It used
a bespoke service
method called directly from syncApplication(), creating two mental
models, two validation
styles, and two cache invalidation patterns. Now there's one unified
pipeline for all
manifest entities.
## What changed
### Entity refactor:
- ApplicationVariableEntity now extends SyncableEntity (gains
universalIdentifier,
non-nullable applicationId with CASCADE, soft-delete via deletedAt)
### New flat entity layer (flat-application-variable/):
- Type, maps type, editable properties constant, entity-to-flat
converter, cache service,
module
### New migration pipeline wiring:
- Manifest converter
(fromApplicationVariableManifestToUniversalFlatApplicationVariable)
- Validator service (FlatApplicationVariableValidatorService)
- Builder service
(WorkspaceMigrationApplicationVariableActionsBuilderService)
- Create/Update/Delete action handlers with secret encryption hooks
- Registered in orchestrator, builder module, runner module, and all
type registries
### Removed bespoke path:
- Deleted upsertManyApplicationVariableEntities from
ApplicationVariableEntityService
- Removed its call from ApplicationSyncService.syncApplication()
- Kept update() (operator-set value at runtime) and getDisplayValue()
(runtime display)
### Database migration:
- Instance command to add columns, backfill universalIdentifier, enforce
NOT NULL
constraints, and update indexes
## Test plan
- npx nx typecheck twenty-server passes (0 errors)
- Unit tests pass (application-variable.service.spec.ts,
build-env-var.spec.ts)
- Install an app with applicationVariables in its manifest → variables
appear with correct
universalIdentifier
- Update app manifest (add/remove/modify a variable) → migration
pipeline handles diff
correctly
- Operator-set value via update endpoint persists correctly with
encryption
- Uninstall app → variables cascade-deleted
- app dev --once on example app syncs without errors
## Summary
PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but
bypassing the standard sync pipeline — manifest sync called the bespoke
`ApplicationOAuthProviderService.upsertManyFromManifest()` instead of
going through the workspace-migration orchestrator like every other
SyncableEntity. Anything that assumed *"all SyncableEntity values flow
through the same pipeline"* (dev UI sync tracking, verification tooling)
was wrong about ConnectionProvider — that's the inconsistency this PR
closes.
This PR follows the `.cursor/skills/syncable-entity-*` guides
religiously, all six steps.
## What changes
**Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`)
- Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared)
- Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops
the ad-hoc columns since the base class provides them, adds `deletedAt`,
drops the old `(applicationId, universalIdentifier)` unique in favour of
SyncableEntity's `(workspaceId, universalIdentifier)`)
- `FlatConnectionProvider`, `FlatConnectionProviderMaps`,
`FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`,
`UniversalFlatConnectionProvider`, six action types
- Register in **all** the central registries:
`AllFlatEntityTypesByMetadataName`,
`ALL_METADATA_ENTITY_BY_METADATA_NAME`,
`ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`,
`ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`,
`ALL_METADATA_SERIALIZED_RELATION`,
`ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`,
`WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`),
`METADATA_EVENTS_TO_EMIT`
- `case 'connectionProvider':` in seven discriminated-union switches
(`derive-metadata-events-*`, `optimistically-apply-*`,
`enrich-create-*`)
**Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`)
- `WorkspaceFlatConnectionProviderMapCacheService` (extends
`WorkspaceCacheProvider`, decorated with `@WorkspaceCache`,
soft-delete-aware)
- `fromConnectionProviderEntityToFlatConnectionProvider` util
- `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util
- `FlatConnectionProviderModule` wires the cache service
- Wired the manifest converter into
`compute-application-manifest-all-universal-flat-entity-maps`
**Step 3 — Builder & Validation**
(`@syncable-entity-builder-and-validation`)
- `FlatConnectionProviderValidatorService` — never throws, returns error
arrays; uses indexed `byUniversalIdentifier` for the (name,
applicationUniversalIdentifier) uniqueness check (no
`Object.values().find()` on the hot path)
- `WorkspaceMigrationConnectionProviderActionsBuilderService`
- Registered in both validators-module + builder-module
- **Wired into the orchestrator** (the most-commonly-forgotten step per
the rule) — constructor inject, destructure
`flatConnectionProviderMaps`, `validateAndBuild`, append actions to the
final migration
**Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`)
- Three handlers (create / update / delete) using the canonical
`WorkspaceMigrationRunnerActionHandler` mixin
- Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule`
**Step 5 — Integration** (`@syncable-entity-integration`)
- Delete the `upsertManyFromManifest` bypass on
`ApplicationOAuthProviderService`
- Remove the bypass call from `ApplicationSyncService` — manifest sync
now flows through the standard pipeline
- Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule`
(no longer needed)
- Import `FlatConnectionProviderModule` from
`ApplicationOAuthProviderModule` to keep the cache discoverable
- 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`,
`CONNECTION_PROVIDER_NOT_FOUND`,
`CONNECTION_PROVIDER_NAME_ALREADY_EXISTS`
**Migration**
- Generated via `database:migrate:generate` (instance command
`1777896012579`): drops the old `(applicationId, universalIdentifier)`
unique constraint, adds `deletedAt` column, adds the `(workspaceId,
universalIdentifier)` unique index that `SyncableEntity` requires.
- Verified clean — a second `migrate:generate` pass produces zero drift.
**Step 6 — Tests** (`@syncable-entity-testing`)
- 3 new specs for the manifest converter (defaults, optional fields,
all-fields)
- All 32 existing OAuth-provider tests still pass
- ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven
only), so the GraphQL integration suite that other SyncableEntities ship
doesn't apply here
**Codegen**
- Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk)
against the live schema
## Why this matters
Before:
- `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum)
- But the entity didn't extend `SyncableEntity`
- And the manifest sync bypassed the standard pipeline
- → Verification tooling, dev UI sync tracking, anything iterating over
`ALL_METADATA_NAME` got inconsistent behaviour
After:
- `ConnectionProvider` is a `SyncableEntity` end-to-end
- Single sync path through the workspace-migration orchestrator (same as
`agent`, `skill`, `frontComponent`, `webhook`, …)
- One mental model
## Out of scope (deliberate)
- **Renaming the table** from `applicationOAuthProvider` to
`connectionProvider` — the `metadataName` is `connectionProvider` (what
consumers see in code); the table name is internal. A rename would
balloon this PR with mechanical churn unrelated to the sync-pipeline
wiring. Worth doing as a follow-up.
- **`applicationVariable` SyncableEntity conversion** — the other
manifest-sync holdout. Tracked in #20215.
## Test plan
- [ ] Migration up/down clean against fresh DB
- [ ] Install an app whose manifest declares connection providers —
providers appear in the workspace
- [ ] Re-deploy the app with one provider added, one removed, one
renamed → all reconciled correctly via the sync pipeline
- [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider
entries the same way it shows agents/skills/etc
- [ ] OAuth flow still works (existing connections, new connections,
reconnect, list/get from SDK) — should be unchanged since the runtime
code path didn't move
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Update usageEvent clickhouse table, partitioning, indexing and
projection (auto materialized view) to optimize credit usage queries
- Add caching for available credits and billing subscription
To do in next PR: deprecate enforceCapUsage cron. Bonus : real-time on
billingSubscription
## Summary
- **Split the `rolesPermissions` cache query into parallel queries** to
eliminate a 5-way LEFT JOIN cartesian product. A workspace with a role
having 5 objectPermissions × 4 permissionFlags × 124 fieldPermissions ×
5 RLP predicates × 5 RLP groups was returning 62,000 rows from just ~162
distinct records, causing 10s+ query timeouts on view updates.
- **Added missing `roleId` index on `permissionFlag` table** — the only
permission table without one, which was causing sequential scans on the
JOIN.
## Summary
### Cache invalidation fix
- After migrating object/field permissions to syncable entities (#18609,
#18751, #18567), changes to `flatObjectPermissionMaps`,
`flatFieldPermissionMaps`, or `flatPermissionFlagMaps` no longer
triggered `rolesPermissions` cache invalidation
- This caused stale permission data to be served, leading to flaky
`permissions-on-relations` integration tests and potentially incorrect
permission enforcement in production after object permission upserts
- Adds the three permission-related flat map keys to the condition that
triggers `rolesPermissions` cache recomputation in
`WorkspaceMigrationRunnerService.getLegacyCacheInvalidationPromises`
- Clears memoizer after recomputation to prevent concurrent
`getOrRecompute` calls from caching stale data
### Docker Hub rate limit fix
- CI service containers (postgres, redis, clickhouse) and `docker
run`/`docker build` steps were pulling from Docker Hub
**unauthenticated**, hitting the 100-pull-per-6-hour rate limit on
shared GitHub-hosted runner IPs
- Adds `credentials` blocks to all service container definitions and
`docker/login-action` steps before `docker run`/`docker compose`
commands
- Uses `vars.DOCKERHUB_USERNAME` + `secrets.DOCKERHUB_PASSWORD`
(matching the existing twenty-infra convention)
- Affected workflows: ci-server, ci-merge-queue, ci-breaking-changes,
ci-zapier, ci-sdk, ci-create-app-e2e, ci-website,
ci-test-docker-compose, preview-env-keepalive, spawn-twenty-docker-image
action
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
On top of [previous closed
PR](https://github.com/twentyhq/twenty/pull/18713) from @FelixMalfait :
- add a schema-creation-skipping optimization
- extract a handler-per-operation pattern,
- add runtime input validation guards,
- integrate with the standard workspace cache
- add gql-style error handling
To do/optimize/check :
- gql parsing and null backfilling
## Intro
This PR introduces a **direct GraphQL execution path** that bypasses
per-workspace GraphQL schema generation for workspace data queries (CRUD
on user-defined objects like companies, people, tasks, etc.).
## Why
In the current architecture, every workspace gets its own
dynamically-generated GraphQL schema reflecting its custom objects and
fields. This costs **~20MB of RAM per workspace per pod** and takes time
to build. For a multi-tenant SaaS with thousands of workspaces, this is
a significant infrastructure cost and a latency bottleneck (especially
on cold starts or cache misses).
The insight is that most workspace queries (`findMany`, `createOne`,
`updateOne`, etc.) don't actually *need* the full schema — they can be
routed directly to the existing Common API query runners by parsing the
GraphQL AST and matching resolver names against object metadata. The
schema is only truly needed for introspection, subscriptions, or queries
that mix core and workspace resolvers.
## How It Works
1. A Yoga `onRequest` plugin intercepts incoming GraphQL requests
2. It parses the query AST and checks if all top-level fields map to
generated workspace resolvers (e.g. `findManyCompanies`,
`createOnePerson`)
3. If yes, it executes them directly against the query runners, skipping
schema generation entirely
4. If the query contains introspection, subscriptions, or core-only
resolvers, it falls through to the normal path
5. Even for mixed queries it can't fully handle, it sets
`skipWorkspaceSchemaCreation` to avoid building the schema when
unnecessary
The whole thing is gated behind the
`IS_DIRECT_GRAPHQL_EXECUTION_ENABLED` feature flag for safe incremental
rollout.
**Net effect**: dramatically lower memory footprint and faster response
times for the vast majority of workspace API calls.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
## Summary
Replace the single `metadataVersion` integer with per-entity-type
**collection hashes** for granular metadata staleness detection. The
backend already generates a UUID per flat entity map on each cache
recompute (`crypto.randomUUID()` in `WorkspaceCacheService`); we now
expose these via the minimal metadata endpoint and SSE events so the
frontend can compare and know exactly which entity types are stale.
### Key changes
**Backend:**
- `WorkspaceCacheService.getCacheHashes()` — new public method that
reads only `:hash` keys from Redis without fetching full data
- `MinimalMetadataDTO` — added `collectionHashes: Record<string,
string>` (JSON scalar mapping `AllMetadataName` → collection hash),
removed `metadataVersion`
- `MetadataEventDTO` — added optional `updatedCollectionHash` field to
SSE events
- `MetadataEventsToDbListener` — reads the collection hash for the
affected entity type after cache invalidation and attaches it to the SSE
event before publishing
- `MinimalMetadataService` — no longer queries the workspace table; uses
`getCacheHashes()` for all flat entity maps and maps cache keys to
`AllMetadataName` locally
**Frontend:**
- `metadataCollectionHashesState` — new Jotai atom with
`atomWithStorage` + `getOnInit: true` storing
`Partial<Record<MetadataEntityKey, string>>`
- `mapAllMetadataNameToEntityKey()` — explicit mapping from backend
`AllMetadataName` to frontend `MetadataEntityKey` (23 entries)
- `useLoadMinimalMetadata` — stores `collectionHashes` from server,
computes `staleEntityKeys` by comparing local vs server hashes
- `patchMetadataStoreFromSSEEvent()` — accepts optional
`updatedCollectionHash` and updates `metadataCollectionHashesState`
- All 11 SSE effect components — pass
`eventDetail.updatedCollectionHash` through to the patch function
- `useStaleMetadataEntities` — new hook returning entity keys missing
from collection hashes (not yet loaded/synced)
- `resetMetadataStore()` — also clears collection hashes
- Deleted `metadataVersionState` (superseded by collection hashes)
### Design decisions
- **No change to hash generation** — existing `crypto.randomUUID()` is
sufficient. Hashes are persisted in Redis, survive server restarts, and
change only on `invalidateAndRecompute`.
- **"Collection hash" naming** — used consistently to clarify the hash
represents an entire entity collection (e.g., all views), not a single
record.
- **Mapping localized** — backend `WorkspaceCacheKeyName` →
`AllMetadataName` mapping lives in the minimal metadata service.
Frontend `AllMetadataName` → `MetadataEntityKey` mapping lives in a
local utility. Nothing in `twenty-shared`.
- **Backward compatible** — `collectionHashes` is additive;
`updatedCollectionHash` is nullable.
## PR Description
- Uses `expr-eval` to enable front components (SDK plugins) to define
conditional availability as declarative expressions.
- Moves shared types and constants to `twenty-shared`
- Introduces a `conditionalAvailabilityExpression` field on
`CommandMenuItemEntity`, allowing command menu items to store an
`expr-eval` compatible expression string that is evaluated against a
CommandMenuContext to determine if the item should be shown.
- Creates an esbuild transform plugin
`conditional-availability-transform-plugin` in `twenty-sdk` that
converts TypeScript conditional availability expressions into
`expr-eval` compatible syntax at build time, so SDK developers can write
natural TS expressions that get transformed to evaluable strings.
- Removes deprecated `forceRegisteredActionsByKey` state and its usage.
- Creates `useCommandMenuContext` hook that builds the full
`CommandMenuContext` object from React state, which is then passed to
`useCommandMenuItemFrontComponentActions` for evaluating conditional
availability expressions.
Fixes https://github.com/twentyhq/core-team-issues/issues/2192
This PR implements what is necessary to re-create the query that we
build on the frontend to obtain the returned object record from a
mutation, but on the backend, which was only partially implemented for
REST API.
Usually we want to have relations with only their id and label
identifier field to have lighter payloads.
In the event we only had depth 0 fields, with this PR we have all events
with depth 1 relations.
We have depth 2 for many-to-many cases, like updateOne or updateMany
result :
- Junction tables
- Activity target tables
## Context
Introduces a new viewFieldGroup entity that allows grouping view fields
into sections (e.g. "General", "Additional", "Other") within a view.
The page layout fields widget needs a way to organize fields into
sections. Today, views have no concept of field grouping. This PR
introduces the viewFieldGroup entity which sits between a view and its
viewFields, enabling section-based organization.
<img width="401" height="724" alt="Layout - V2 (customize visibility)"
src="https://github.com/user-attachments/assets/6376e2ab-44db-42bf-9d2c-758f56f6b548"
/>
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Problem
The `deleteFromLocalCache` method was only setting `lastHashCheckedAt=0`
instead of actually deleting the cache entry. This caused old versions
to accumulate in the local cache when `invalidateAndRecompute` was
called.
### Flow that causes the leak:
1. `invalidateAndRecompute()` is called
2. `flush()` → `deleteFromLocalCache()` — **only sets
`lastHashCheckedAt=0`, keeps old data**
3. `recomputeDataFromProvider()` → `setInLocalCache()` — **adds new
version with new hash**
4. `cleanupStaleVersions()` — **never called** (only triggered from
`getFromLocalCache` path)
Result: Each `invalidateAndRecompute` call adds a new version to
`entry.versions` without removing the old one.
### Impact
For migration commands (like
`1-17-migrate-attachment-to-morph-relations`) that process thousands of
workspaces, this caused significant memory growth:
- Each workspace calls `getOrRecompute` (version 1)
- Then calls `invalidateAndRecompute` (version 2 added, version 1 stays)
- Memory accumulates as the command processes more workspaces
## Solution
Actually delete the local cache entry in `deleteFromLocalCache`, so
`recomputeDataFromProvider` starts fresh.
```typescript
// Before (memory leak)
if (isDefined(entry)) {
entry.lastHashCheckedAt = 0;
}
// After (proper cleanup)
this.localCache.delete(localKey);
```
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
## Remove logic function layer
Package.json and yarn.lock are now on the application entity, so the
logic function layer is no longer used except as a legacy source for the
1.17 backfill. This PR removes all layer usage outside of that migration
and keeps only the entity for backfill.
### Summary
- **Kept:** `LogicFunctionLayerEntity` and its table, only used by the
1.17 backfill command to read legacy layer data and backfill application
package files.
- **Removed:** All other layer logic: CRUD, cache, resolvers, services,
DTOs, and frontend types. Logic functions now depend only on the
application for package/dependency context.
### Why Dependencies instead of Source for package files
Package.json and yarn.lock are the application’s dependency set and are
stored under the application in **FileFolder.Dependencies**. The build
service and drivers now read them only from Dependencies; nothing is
written to Source for these files.
# Introduction
Important note: This PR officially deprecates the `standardId`, about to
drop col and entity property after this has been merged
Important note2: Haven't updated the optimistic tool to also update the
universal identifier aggregators only the ids one, they should not be
consumed in the runner context -> need to improve typing or either the
optimistic tooling
In this PR we're introducing all the devxp allowing future metadata
incremental universal migration -> this has an impact on all existing
metadata actions handler ( explaining its size )
This PR also introduce workspace agnostic create update actions runner
for both field and object metadata in order to battle test the described
above devxp
Noting that these two metadata are the most complex to handle
Notes:
- A workspace migration is now highly bind to a
`applicationUniversalIdentifier`. Though we don't strictly validate
application scope for the moment
## Next
Migrate both object and field builder to universal comparison
## Universal Actions vs Flat Actions Architecture
### Concept
The migration system uses a two-phase action model:
1. **Universal Actions** - Actions defined using `universalIdentifier`
(stable, portable identifiers like `standardId` + `applicationId`)
2. **Flat Actions** - Actions defined using database `entityId` (UUIDs
specific to a workspace)
### Why This Separation?
- **Universal actions are portable**: They can be serialized, stored,
and replayed across different workspaces
- **Flat actions are executable**: They contain the actual database IDs
needed to perform operations
- **Decoupling**: The builder produces universal actions; the runner
transpiles them to flat actions at execution time
### Transpiler Pattern
Each action handler must implement
`transpileUniversalActionToFlatAction()`:
```typescript
@Injectable()
export class CreateFieldActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'create',
'fieldMetadata',
) {
override async transpileUniversalActionToFlatAction(
context: WorkspaceMigrationActionRunnerArgs<UniversalCreateFieldAction>,
): Promise<FlatCreateFieldAction> {
// Resolve universal identifiers to database IDs
const flatObjectMetadata = findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: allFlatEntityMaps.flatObjectMetadataMaps,
universalIdentifier: action.objectMetadataUniversalIdentifier,
});
return {
type: action.type,
metadataName: action.metadataName,
objectMetadataId: flatObjectMetadata.id, // Resolved ID
flatFieldMetadatas: /* ... transpiled entities ... */,
};
}
}
```
### Action Handler Base Class
`BaseWorkspaceMigrationRunnerActionHandlerService<TActionType,
TMetadataName>` provides:
- **`transpileUniversalActionToFlatAction()`** - Abstract method each
handler must implement
- **`transpileUniversalDeleteActionToFlatDeleteAction()`** - Shared
helper for delete actions
## FlatEntityMaps custom properties
Introduced a `TWithCustomMapsProperties` generic parameter to control
whether custom indexing structures are included:
- **`false` (default)**: Returns `FlatEntityMaps<MetadataFlatEntity<T>>`
- used in builder/runner contexts
- **`true`**: Returns the full maps type with custom properties (e.g.,
`byUserWorkspaceIdAndFolderId`) - used in cache contexts
## Create Field Actions Refactor
Refactored create-field actions to support relation field pairs
bundling.
**Problem:** Relation fields (e.g., `Attachment.targetTask` ↔
`Task.attachments`) couldn't resolve each other's IDs during
transpilation because they were in separate actions with independent
`fieldIdByUniversalIdentifier` maps.
**Solution:**
- Removed `objectMetadataUniversalIdentifier` from
`UniversalCreateFieldAction` and `objectMetadataId` from
`FlatCreateFieldAction` - each field now carries its own
- Runner groups fields by object internally and processes each table
separately
- Split aggregator into two focused utilities:
- `aggregateNonRelationFieldsIntoObjectActions` - merges non-relation
fields into object actions
- `aggregateRelationFieldPairs` - bundles relation pairs with shared
`fieldIdByUniversalIdentifier`
# Introduction
In this PR we're refactoring the `FlatEntity` type to become a superset
of the `UniversalFlatEntity`.
Right now we're storing all the extra properties in `__universal`
property, at some point it might just be sibling to other entity and we
might rely on the `propertiesToCompare` constants and TypeScript
allowing passing a superset type into a smaller subset type
## FromTo utils
The entity to flat entity method now computes the universal information,
standardized a typing and pattern to do
## Example
Also strictly type
```ts
"bbb019ea-6205-498c-aea5-67bc53bce8a9": {
"workspaceId": "20202020-1c25-4d02-bf25-6aeccf7ea419",
"universalIdentifier": "20202020-d111-4d11-8d11-da5ab0a11002",
"applicationId": "d01b010d-b984-465b-b40b-370e954e5188",
"id": "bbb019ea-6205-498c-aea5-67bc53bce8a9",
"pageLayoutTabId": "791a512f-169f-4209-b731-aa86716668c6",
"title": "Deals by Company",
"type": "GRAPH",
"objectMetadataId": "9e14efea-df5b-4c0e-aba9-cfe455f32397",
"gridPosition": { "row": 0, "column": 6, "rowSpan": 6, "columnSpan": 6 },
"configuration": {
"color": "orange",
"orderBy": "FIELD_ASC",
"timezone": "UTC",
"displayLegend": true,
"displayDataLabel": false,
"showCenterMetric": true,
"configurationType": "PIE_CHART",
"firstDayOfTheWeek": 0,
"aggregateOperation": "COUNT",
"groupBySubFieldName": "name",
"groupByFieldMetadataId": "6673ff18-63d2-47a1-8f85-2b9b09ca27a5",
"aggregateFieldMetadataId": "8d64ee41-5dd4-4de6-945a-7c0c18399715"
},
"createdAt": "2026-01-28T14:08:52.140Z",
"updatedAt": "2026-01-28T14:08:52.140Z",
"deletedAt": null,
"__universal": {
"universalIdentifier": "20202020-d111-4d11-8d11-da5ab0a11002",
"applicationUniversalIdentifier": "20202020-64aa-4b6f-b003-9c74b97cee20",
"pageLayoutTabUniversalIdentifier": "20202020-d011-4d11-8d11-da5ab0a01001",
"objectMetadataUniversalIdentifier": "20202020-9549-49dd-b2b2-883999db8938",
"gridPosition": {
"row": 0,
"column": 6,
"rowSpan": 6,
"columnSpan": 6
},
"configuration": {
"color": "orange",
"orderBy": "FIELD_ASC",
"timezone": "UTC",
"displayLegend": true,
"displayDataLabel": false,
"showCenterMetric": true,
"configurationType": "PIE_CHART",
"firstDayOfTheWeek": 0,
"aggregateOperation": "COUNT",
"groupBySubFieldName": "name",
"aggregateFieldMetadataUniversalIdentifier": "20202020-d01a-4131-8a31-f123456789ab",
"groupByFieldMetadataUniversalIdentifier": "20202020-cbac-457e-b565-adece5fc815f"
}
}
},
```
Implements a new syncable `navigationMenuItem` entity in the core schema
to replace the workspace `favorite` entity.
## Next Steps
- Frontend integration ([separate
PR](https://github.com/twentyhq/twenty/pull/17268))
- Data migration (separate PR)
# Introduction
In this PR we're migrating the serverless function service that was
using the SF repo directly to the v2 build and runner.
The whole serverless engine now deals with flat entities only
## Resolvers
Refactored the resolvers ( serverlessFunction, route, database and cron
trigger) :
- return types to `dto`
- Standardized the flat to dto transpilation within the resolvers
- Find and findMany passing by the cached data
## Services
Refactored the services ( serverlessFunction, route, database and cron
trigger) :
- return type to be `flat`
- always calling v2 and computing cache
## New additional caches
- application variables ( cf
https://github.com/twentyhq/core-team-issues/issues/2116 )
- serverless function layer
## What to test:
- CRUD ( database trigger ✅ , route trigger, cron trigger, serverless
function through workflows ✅ )
- Duplicating a workflow with a serverless function code node ✅
## Concerns
We need to implement the cron that will hard delete soft deleted s3
serverless functions, not in this PR though ( cf
https://github.com/twentyhq/twenty/pull/17285#discussion_r2709168570 and
https://github.com/twentyhq/core-team-issues/issues/2118 )
## Context
Due to RLS feature, workspaceMember entity and its properties is
becoming necessary in different places of the backend. To avoid querying
many times, this PR adds a map of workspace members per workspaceId,
leveraging WorkspaceCache pattern for WorkspaceEntity (in opposition
with CoreEntity)
Due to its content (mostly PII), I prefer to cache it locally only (node
process VS Redis) using localDataOnly.
TODO: invalidation logic when the workspaceMember object is updated
(more precisely, when its field map is updated). There is no easy way
today to update that list so it can be done in another PR imho
As part of the extensibility effort, we are introducing a new engine
entity called "Front Component". This represents a dynamic react
component that will be rendered in CommandMenu actions or in PageLayout
widgets
This PR introduce the entity and all the necessary boilerplate to make
it syncable and cachable in the engine
## Summary
This PR introduces a Skills system for AI agents, inspired by the [Agent
Skills specification](https://agentskills.io/specification).
## Changes
### Backend
- **SkillEntity**: New database entity with migration for storing skills
- **V2 Sync Mechanism**: Implemented FlatSkill, builders, validators,
and action handlers following the v2 flat entity pattern
- **Standard Skills**: Pre-defined skills (workflow-building,
data-manipulation, dashboard-building, metadata-building, research,
code-interpreter, xlsx, pdf, docx, pptx)
- **GraphQL API**: CRUD operations for skills with proper guards and
permissions
- **Workspace Cache**: Integrated skills into the workspace cache system
### Frontend
- **Skills Table**: Searchable table in AI settings showing all skills
- **Skill Form**: Create/edit page with Label (primary), Description,
and Content (markdown editor)
- **API Name**: Following existing patterns, name is derived from label
with advanced settings toggle for custom API names
- **Standard vs Custom**: Standard skills are read-only, custom skills
can be edited/deleted
## Key Design Decisions
- Skills are stored in the database (Salesforce-like approach) rather
than files
- Name is derived from Label by default (isLabelSyncedWithName pattern)
- Skills reference functions/files via @ mentions in markdown content
rather than explicit relations
- Standard skills are synced from code, custom skills are created via UI
## Screenshots
Skills table and form UI follow existing settings patterns.
## Testing
- [x] Lint passes
- [x] Typecheck passes
- [ ] CI tests
## Introduction
On the side hanlded PR with a // agents on another repo
Made several iterations to fix behavior and direction
Find below auto-generated PR description
closes https://github.com/twentyhq/core-team-issues/issues/2037
Created generic tooling for entity circular dep checking, will be useful
for permissions validation too @Weiko
## Migrate `viewFilterGroup` entity to v2 flat architecture
### Summary
Migrates the `viewFilterGroup` entity from v1 to the v2 flat entity
architecture, following the established patterns for other v2 entities
like `viewFilter`, `view`, and `viewField`.
### Changes
**Types & Constants**
- Added `FlatViewFilterGroup` and `FlatViewFilterGroupMaps` types
- Added editable properties constant for `viewFilterGroup`
- Registered `viewFilterGroup` in `ALL_METADATA_NAME`,
`ALL_METADATA_RELATION_PROPERTIES`,
`ALL_METADATA_MANY_TO_ONE_RELATIONS`, and related constants
**Cache Service**
- Created `WorkspaceFlatViewFilterGroupMapCacheService` with proper
relation loading for `viewFilters` and `childViewFilterGroups`
- Updated `WorkspaceFlatViewMapCacheService` to load `viewFilterGroups`
relation
**Builder & Validator**
- Created `WorkspaceMigrationV2ViewFilterGroupActionsBuilderService`
- Created `FlatViewFilterGroupValidatorService` with creation, update,
and deletion validation
- Integrated validation into the orchestrator service (runs before
`viewFilter` validation)
**Action Handlers**
- Created create, update, and delete action handlers for
`viewFilterGroup`
**Service Migration**
- Rewrote `ViewFilterGroupService` to use v2 migration pattern with
`WorkspaceMigrationValidateBuildAndRunService`
- Created utility functions for transforming DTOs to flat entities
**Database Migration**
- Added migration to make `parentViewFilterGroupId` foreign key
deferrable (handles self-referential parent/child insertions)
**ViewFilter Integration**
- Added `viewFilterGroupId` validation in
`FlatViewFilterValidatorService`
- Updated `viewFilter` many-to-one relations to include
`viewFilterGroup`
**Tests**
- Added integration tests for successful creation, update, deletion, and
destruction
- Added failing test cases for non-existent entities and invalid
references
- Added failing test for `viewFilter` creation with non-existent
`viewFilterGroupId`
### Breaking Changes
None - existing API contracts are preserved.
## Context
This PR adds the core structure for RLS implementation:
- RLS data model
- RLS service layer
- RLS WorkspaceMigration and Syncable Entity + cache + Validations
- RLS resolver layer
- ORM layer with RLS Predicate to ORM WHERE clause conversion with
workspaceMember record transposition
Tests are missing though
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> Establishes core row-level permissions infrastructure and enforcement
across the stack.
>
> - Backend: new `rowLevelPermissionPredicate` and
`rowLevelPermissionPredicateGroup` entities, TypeORM migration, feature
flag `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED`, flat-entity
maps/cache wiring, services and GraphQL resolvers for CRUD, and
inclusion of `workspaceMember` in auth context
> - ORM: applies row-level permission predicates to SELECT, DELETE, and
SOFT DELETE query builders; propagates context through
GlobalWorkspaceOrmManager/EntityManager
> - GraphQL: generated schema/types/queries/mutations for
creating/updating/deleting/fetching predicates and groups
> - Frontend: settings page adds a gated "Record-level" section
(placeholder) and metadata error handler labels for new entities
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fe955cc458. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Two challenges with error messages
- always provide a useful/meaningful error message for the end user
instead of the generic one. eg: show "Wrong password" and not "An error
occured"
- avoid technical details unless error regards a technical feature. eg:
show "An error occured" and not "Invalid post-hook payload."; but do
show "Invalid issuer URL." as it occurs while configuring SSO
What this PR does
- Make userFriendlyMessage mandatory for widely used
GraphqlQueryRunnerException and CommonQueryRunnerException, so that
developers are forced to ask themselves what the error message should
be, and as it contains very wide error codes (eg: "Bad request") which
should not be mapped to just one default message
- Keep userFriendlyMessage optional for service-specific exceptions (eg:
workflowStepExecutorException), but convert the error code to
userFriendlyMessage mapper to a switch case function with a typecheck
ensuring that all codes are mapped to a message. These default messages
are still overridable where they are thrown.
# Introduction
@bosiraphael has to introduce async validators and feature flag
contextual validator
In this way in this PR we make all entity validators asyncable
Also added an `additionalCacheDataMaps` to the low level args validators
## Summary
This PR enforces that all custom exceptions must provide a
`userFriendlyMessage`, ensuring end users always see readable error
messages.
## Changes
### Core Changes
- **`CustomException` simplified**: Removed the `ForceFriendlyMessage`
generic parameter - `userFriendlyMessage` is now always required
- **Type safety**: The constructor now requires `{ userFriendlyMessage:
MessageDescriptor }` (no longer optional)
### Updated Files
- **74+ exception classes** updated to provide default user-friendly
messages using Lingui `msg` macro
- Each exception class has a sensible fallback message (e.g., `msg\`An
authentication error occurred.\``)
- Exception classes that had code-specific message maps retain their
behavior
## Benefits
- **Compile-time enforcement**: Forgetting to add a user-friendly
message now causes a TypeScript error
- **Better UX**: End users always see a localized, human-readable error
message
- **Simpler API**: No more boolean generic parameter to think about
## Testing
- `npx nx run twenty-server:typecheck` passes
- `npx nx run twenty-server:lint` passes
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> Enforces `userFriendlyMessage` on `CustomException` and updates all
exception classes to supply localized default messages, with
filters/tests adjusted accordingly.
>
> - **Core**:
> - Enforce required `userFriendlyMessage` in `CustomException` (remove
optional generic; constructor now requires `{ userFriendlyMessage:
MessageDescriptor }`).
> - **Exceptions**:
> - Update ~70+ exception classes to set default localized messages via
Lingui `msg` maps and pass them in constructors (e.g., `AuthException`,
`ObjectMetadataException`, `FieldMetadataException`, etc.).
> - Add fallback messages where needed (e.g., `INTERNAL_SERVER_ERROR` or
domain-specific defaults).
> - **HTTP/GraphQL Filters**:
> - Ensure fallbacks create `UnknownException` with `msg` for
user-friendly text in REST/GraphQL exception filters.
> - **Tests**:
> - Adjust unit tests to pass `userFriendlyMessage` to exceptions.
> - Update Jest snapshots to include `extensions.userFriendlyMessage` or
message objects where applicable.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
221004fdfc. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
# Introduction
Migrating `pageLayoutTab` to the v2 engine
- Types and constants
- Builder and validate
- Runner
Introduced a new `StrictSyncableEntity` that enforces that
`universalIdentifier` and `applicationId` are defined
As these entities are brand new we could enforce this rule already
This still requires a migration command to associate the existing
entities to custom workspace application instance and define
universalIdentifier
Handled retro-comp of the migration through a migration as upgrade
command fallback
---------
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
## Problem
buildEntityMetadatas in GlobalWorkspaceOrmManager is computationally
expensive and was running on every executeInWorkspaceContext call. This
method uses
TypeORM's EntitySchemaTransformer and EntityMetadataBuilder to build
metadata for all workspace entities (30-50+ objects with many fields
each).
The resulting EntityMetadata[] is not serialisable which means it cannot
be cached in Redis because they contain:
- Circular references
- Functions/methods
- References to the DataSource instance
## Solution
Extended the workspace cache system to support local-only caching, then
created a cache provider for entityMetadatas.
## Implementation details
Updated @WorkspaceCache decorator (workspace-cache.decorator.ts)
- Added localOnly?: boolean option to skip Redis storage for
non-serializable data
Created WorkspaceEntityMetadatasCacheService
- Computes entity metadatas from DB to avoid race condition, this is
acceptable
Simplified GlobalWorkspaceOrmManager
- Now fetches entityMetadatas from cache instead of rebuilding on every
call
Updated Workspace migration runner - the only entry point where metadata
can change
- Now invalidate the new 'entityMetadata' local cache when
shouldIncrementMetadataGraphqlSchemaVersion is true (== field/object
mutations)
# Introduction
Closes https://github.com/twentyhq/core-team-issues/issues/1980
In this PR we migrate the agent from v1 to v2.
## New FlatRoleTargetByAgentIdMaps
Derivated the `flatRoleTargetMaps` to be building a
`flatRoleTargetByAgentIdMaps` to ease retrieving a roleId to associate
to an agent
## Coverage
Added strong coverage on both failing and successful CRU agents
operations
---------
Co-authored-by: Weiko <corentin@twenty.com>
## Context
We've recently introduced a new workspace cache service which now acts
as a cache access and local storage for all workspace related data,
deprecating the individual specific services.
- Better performance through multiple caching/fetching strategies
- Consistent data access patterns across the codebase
- Reduced redis queries through MGET/MSET/PIPELINE with multiple cache
keys
## Context
Deprecating the old objectMetadataMap type in favour of split flat
entities to match with our new caching.
In the long run, trying to achieve:
- Better performance through caching
- Consistent data access patterns across the codebase
- Reduced database queries
Now that everything is based on flat entities, which are cached, we can
finish the refactoring of workspace context cache which should already
improve performances.
Then the last step will be to consume that new cache in the new global
datasource to get rid of the many workspace datasources stored in the
server
## Context
Implementing a single service managing all the cache scoped to a
workspace, this will be dynamically injected as a WorkspaceContext in
the app during a request lifetime (ingested by the future global
datasource for example).
Usage:
```typescript
this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
// Everything here will have access to a workspaceContext, containing all the cache data + a ready to use datasource with workspace scoped metadata, permissions, feature flags, etc...
// Internally will call loadWorkspaceContext
}
```
Note: executeInWorkspaceContext will probably be owned by a higher level
service later and not only the ORM.
```typescript
private async loadWorkspaceContext(
authContext: WorkspaceAuthContext,
): Promise<WorkspaceContext> {
const workspaceId = authContext.workspace.id;
const cache =
await this.workspaceContextCacheService.get<WorkspaceContextData>(
workspaceId,
[
'objectMetadataMaps',
'metadataVersion',
'featureFlagsMap',
'permissionsPerRoleId',
],
);
return {
authContext,
objectMetadataMaps: cache.objectMetadataMaps,
metadataVersion: cache.metadataVersion,
featureFlagsMap: cache.featureFlagsMap,
permissionsPerRoleId: cache.permissionsPerRoleId,
};
}
```
The cache retrieval strategy is as followed:
```
- Check if there is an ongoing promise fetching data from the cache =>
return the promise.
- Check in the local cache entry if lastCheckedAt has expired. If not,
return as it is without querying redis.
- Check in redis the cache entry hash and compare with local cache entry
hash, if they are the same return the local cache entry data
- Check in redis the cache entry data, if it's there return it and store
it into the local cache entry data and update local cache entry hash. If
it's not there recompute the data by querying the DB and update both
redis and local cache
```