## 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 ```
The #1 Open-Source CRM
🌐 Website · 📚 Documentation · Roadmap ·
Discord ·
Figma
Installation
See: 🚀 Self-hosting 🖥️ Local Setup
Does the world need another CRM?
We built Twenty for three reasons:
CRMs are too expensive, and users are trapped. Companies use locked-in customer data to hike prices. It shouldn't be that way.
A fresh start is required to build a better experience. We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
We believe in Open-source and community. Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
What You Can Do With Twenty
Please feel free to flag any specific needs you have by creating an issue.
Below are a few features we have implemented to date:
- Personalize layouts with filters, sort, group by, kanban and table views
- Customize your objects and fields
- Create and manage permissions with custom roles
- Automate workflow with triggers and actions
- Emails, calendar events, files, and more
Personalize layouts with filters, sort, group by, kanban and table views
Customize your objects and fields
Create and manage permissions with custom roles
Automate workflow with triggers and actions
Emails, calendar events, files, and more
Stack
- TypeScript
- Nx
- NestJS, with BullMQ, PostgreSQL, Redis
- React, with Recoil, Emotion and Lingui
Thanks
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on Twitter or LinkedIn
- Join our Discord
- Improve translations on Crowdin
- Contributions are, of course, most welcome!




