Fixes https://github.com/twentyhq/twenty/issues/15809
## Context
We recently introduced a global datasource now consuming workspace
context from ALS store however the authContext was missing (only the
workspaceId was there) which "broke" event emission, now missing the
workspaceMemberId
This PR adds the missing authContext so we can access from anywhere in
the datasource. We are still passing it as a parameters on repository
level for legacy but in theory we should be able to remove it from
everywhere and consume the context
<img width="929" height="253" alt="Screenshot 2025-11-13 at 18 58 16"
src="https://github.com/user-attachments/assets/3e04f264-95e6-4831-94f3-fc01603f19bd"
/>
## Context
This PR introduces a Global Workspace DataSource that consolidates
workspace-specific database access through a single TypeORM DataSource
instance with AsyncLocalStorage-based context management instead of N
workspace datasources.
- Created GlobalWorkspaceDataSource extending TypeORM's DataSource to
manage multiple workspaces with entity metadata caching (1-hour TTL)
- Implemented AsyncLocalStorage for workspace context propagation
(WorkspaceContextForStorage) containing workspace ID, metadata,
permissions, and feature flags
- Modified query execution flow to wrap operations in workspace context
via GlobalWorkspaceOrmManager.executeInWorkspaceContext()
- Added schema name to entity schemas for proper multi-tenant database
separation
Next:
- use the new global workspace datasource everywhere and deprecate
workspace datasource factory
- improve metadata caching using a short TTL to avoid multiple calls to
redis
- Leverage the new WorkspaceContextALS and put it higher in the request
hierarchy to have access to permission, metadata and featureflag
everywhere --- build it manually for commands --- find a way to
propagate it in jobs?
- Remove PG_POOL patch once we have a unique datasource and increase
global datasource pool size
## Implementation
Why ALS:
1. Automatic Per-Request Isolation
With schema-based multi-tenancy, each workspace has its own PostgreSQL
schema
(e.g. workspace_20202020-1c25-4d02-bf25-6aeccf7ea419).
The critical challenge is ensuring that concurrent requests from
different tenants don't interfere with each other.
```typescript
// Request A (Workspace 1) and Request B (Workspace 2) executing concurrently
// Without ALS: Race condition — they'd share the same global state!
// With ALS: Each request has isolated context ✓
```
ALS automatically isolates context per async execution chain, so:
- Request from Tenant A → ALS stores workspaceId: "tenant-a" → Queries
hit workspace_tenant_a schema
- Request from Tenant B → ALS stores workspaceId: "tenant-b" → Queries
hit workspace_tenant_b schema
✅ No interference, even when executing simultaneously on the same
Node.js event loop.
2. No Manual Context Passing
Before ALS, you'd need to pass workspaceId through every function call:
```typescript
// ❌ Without ALS - Context threading nightmare
getRepository(workspaceId, entity)
→ createEntityManager(workspaceId)
→ getMetadata(workspaceId, target)
→ findInCache(workspaceId, cacheKey)
```
With ALS:
```typescript
// ✅ With ALS - Clean, implicit context
getRepository(entity) // Reads workspaceId from ALS
→ createEntityManager() // Reads workspaceId from ALS
→ getMetadata(target) // Reads workspaceId from ALS
→ findInCache(cacheKey) // Reads workspaceId from ALS
```
example
```typescript
override findMetadata(target: EntityTarget<ObjectLiteral>): EntityMetadata | undefined {
const context = getWorkspaceContext(); // 👈 Automatically gets the right workspace!
const { workspaceId, metadataVersion } = context;
const cacheKey = `${workspaceId}-${metadataVersion}`;
// ... returns metadata for THIS workspace's schema
}
```
3. Async Chain Propagation
Node.js operations are heavily async. ALS automatically propagates
context through:
- async/await chains
- Promise chains
- Callbacks
```typescript
executeInWorkspaceContext(workspaceId, async () => {
await prepareContext(); // Has context ✓
const results = await run(); // Has context ✓
await enrichResults(); // Has context ✓
// Even nested async operations maintain context!
await Promise.all([
saveToCache(), // Has context ✓
emitEvent(), // Has context ✓
logMetrics(), // Has context ✓
]);
});
```
5. Schema-Specific Metadata Caching
The implementation caches entity metadata per workspace + version:
```typescript
// Cache key format: "workspaceId-metadataVersion"
const cacheKey = `${workspaceId}-${metadataVersion}`;
```
Why this matters with schemas:
- Each workspace has different table structures (custom fields, objects)
- EntitySchema includes schema: "workspace_xxx" property
- Each cached metadata points to the correct schema
ALS ensures getWorkspaceContext() returns the right workspaceId,
so you always get the correct schema's metadata from cache.
6. Single DataSource for All Tenants
The key change here:
```typescript
// ❌ Old approach: One DataSource per tenant
const dataSourceTenantA = new DataSource({ schema: 'workspace_a' });
const dataSourceTenantB = new DataSource({ schema: 'workspace_b' });
// Problem: Hundreds of DB connection pools!
```
```typescript
// ✅ New approach: One shared DataSource + ALS context
const globalDataSource = new GlobalWorkspaceDataSource();
// ALS determines which schema to use at runtime
```
When you call:
```typescript
globalDataSource.getRepository('person');
```
It internally does:
```typescript
const context = getWorkspaceContext(); // Gets current tenant from ALS
const metadata = this.findMetadata('person'); // Finds metadata for THIS tenant's schema
// EntityMetadata includes: schema: "workspace_20202020-1c25..."
// TypeORM automatically queries: SELECT * FROM "workspace_20202020-1c25...".person
```
7. Request Lifecycle Example
```typescript
// 1. GraphQL request arrives: "query people { ... }"
// 2. Middleware extracts authContext.workspace.id = "tenant-a"
// 3. Query runner wraps execution in ALS:
executeInWorkspaceContext("tenant-a", async () => {
// 4. Everything inside has access to workspace context:
const repo = getRepository('person'); // ALS → tenant-a
const metadata = getMetadata('person'); // ALS → tenant-a → cache["tenant-a-v5"]
// 5. TypeORM builds query with correct schema:
// SELECT * FROM "workspace_tenant_a"."person" WHERE ...
// 6. Even nested calls work:
await saveAuditLog(); // ALS → tenant-a → correct audit schema
await emitWebhook(); // ALS → tenant-a → correct tenant webhook
});
// 7. Request completes, ALS context automatically cleaned up
```
8. Safety & Error Prevention
```typescript
// If you forget to set context:
const context = getWorkspaceContext();
// ❌ Throws: "Workspace context not set..."
// Fails fast rather than querying wrong schema!
// Can't accidentally query wrong tenant:
// Context is immutable within execution scope
```
**Before**
- any user with workpace_members permission was able to remove a user
from their workspace. This triggered the deletion of workspaceMember +
of userWorkspace, but did not delete the user (even if they had no
workspace left) nor the roleTarget (acts as junction between role and
userWorkspace) which was left with a userWorkspaceId pointing to
nothing. This is because roleTarget points to userWorkspaceId but the
foreign key constraint was not implemented
- any user could delete their own account. This triggered the deletion
of all their workspaceMembers, but not of their userWorkspace nor their
user nor the roleTarget --> we have orphaned userWorkspace, not
technically but product wise - a userWorkspace without a workspaceMember
does not make sense
So the problems are
- we have some roleTargets pointing to non-existing userWorkspaceId
(which caused https://github.com/twentyhq/twenty/issues/14608 )
- we have userWorkspaces that should not exist and that have no
workspaceMember counterpart
- it is not possible for a user to leave a workspace by themselves, they
can only leave all workspaces at once, except if they are being removed
from the workspace by another user
**Now**
- if a user has multiple workspaces, they are given the possibility to
leave one workspace while remaining in the others (we show two buttons:
Leave workspace and Delete account buttons). if a user has just one
workspace, they only see Delete account
- when a user leaves a workspace, we delete their workspaceMember,
userWorkspace and roleTarget. If they don't belong to any other
workspace we also soft-delete their user
- soft-deleted users get hard deleted after 30 days thanks to a cron
- we have two commands to clean the orphans roleTarget and userWorkspace
(TODO: query db to see how many must be run)
**Next**
- once the commands have been run, we can implement and introduce the
foreign key constraint on roleTarget
Fixes https://github.com/twentyhq/twenty/issues/14608
As title
- adds decorators in twenty-sdk
- update twenty-cli load-manifest to it gets @FieldMetadata infos +
testing
- update twenty-server so it CRUD fields properly, using
universalIdentifier
- Fix UI so we can update managed objects records
- move FieldMetadata items from twenty-server to twenty-shared
## Description
- This PR approaches to solve
https://github.com/twentyhq/twenty/issues/15201
- updated `createDryRunResponse` to return fully populated merged record
- this way the frontend can render the populated data it as-is without
recomputing relations
- Dry-run now uses the same nested-relations population path as the
non-dryRun flow
---------
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
For the variables orderBy (for findMany and groupBy), groupBy (for
groupBy) and orderByForRecords (for groupBy), we were wrongfully adding
the foreign key field (eg: pointOfContact) by its joinColumnName (eg:
pointOfContactId) as the variable key (eg. `orderBy: { pointOfContactId:
"AscNullsFirst" } }`. That broke because then this key is used to
identify the field in the parsers.
This went unnoticed because this order / group option is not very
interesting as it is limited to the id for now, but it s still better to
have it work than crash!
before (on findMany)
<img width="1841" height="674" alt="flawn_order_by_pocId"
src="https://github.com/user-attachments/assets/3ccd7604-041d-4b7e-8b6a-c1d268440a45"
/>
after (on findMany)
<img width="1182" height="805" alt="image"
src="https://github.com/user-attachments/assets/e9253683-bcbe-429e-bbef-e334c7cc76af"
/>
To be done in an other PR, move graphql-query-runner handlers in
common-api-query-runner folder (+ renaming + typing improvments)
Fixes :
- totalCount on findMany (Rest)
- getAllSelectedFields did not handle composite field (Rest)
- issue with endCursor (Rest)
- args processing for updateOne and createOne (Rest + Gql)
- fix findDuplicates (Gql)
Done ⬇️
Gql : move delete and destroy logic to common
Rest :
- rename 'delete' handler to 'destroy'
- create soft delete handlers (named 'delete') : one and many
- create destroy many handler
- update doc
--> Rest api gains NEW soft delete one/many + destroy many capabilities
closes : https://github.com/twentyhq/core-team-issues/issues/1579
Implements permission intersection (AND logic) to prevent permission
escalation when agents act on behalf of users.
### Changes:
- **Permission Intersection**: Operations requiring both user AND agent
permissions
- **RoleContext Type**: Unified type supporting single `roleId` or
multiple `roleIds` for intersection
- **CRUD Services**: Updated to accept `roleContext` for granular
permission control
- **Agent Integration**: Chat agents now use user + agent role
intersection for all operations
- **ORM Layer**: Enhanced `getRepository` to support multi-role
permission checks
### Related:
- Part 2 of ["Acting on behalf of user" concept
PR](https://github.com/twentyhq/twenty/pull/15103)
[Closes#1661](https://github.com/twentyhq/core-team-issues/issues/1661)
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
closes https://github.com/twentyhq/core-team-issues/issues/1418
Tested :
- findOne on Rest and Gql
### Vision
#### Common
- Common is kind of renamed Gql base resolver
- Common handles args (filter, values, ...) validation
- Common accepts depth or raw gql selected fields to compute
selectedFields
- Common is directly called by each CommonQueries (findOne, ...)
service, which extend CommonBaseQuery service
- Common sequence :
| - Parse & Validate args (args-handlers, to create)
| - Build query (query-parsers : currently in gql-query-parsers, to
move)
| - Execute query
| - Fetch relation + format
#### Rest
- Simple parsing (without metadata validation)
- Calling Common API
- Simple rest response formatting
#### Gql
- Calling Common API