snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
NODE_ENV=development
PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default
REDIS_URL=redis://localhost:6379
APP_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
IS_MULTIWORKSPACE_ENABLED=true
# The onboarding e2e creates a workspace, which the default rejects.
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false
# This server serves the front build, so both share one origin.
FRONTEND_URL=http://localhost:3000

View File

@@ -0,0 +1,148 @@
# Use this for local setup
NODE_ENV=development
PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default
REDIS_URL=redis://localhost:6379
APP_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false
FRONTEND_URL=http://localhost:3001
# ———————— Optional ————————
# PORT=3000
# ACCESS_TOKEN_EXPIRES_IN=30m
# LOGIN_TOKEN_EXPIRES_IN=15m
# REFRESH_TOKEN_EXPIRES_IN=90d
# FILE_TOKEN_EXPIRES_IN=1d
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
# IS_IMAP_SMTP_CALDAV_ENABLED=true
# CALENDAR_PROVIDER_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
# CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
# IS_BILLING_ENABLED=false
# BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection
# AUTH_PASSWORD_ENABLED=false
# IS_MULTIWORKSPACE_ENABLED=false
# AUTH_MICROSOFT_ENABLED=false
# AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id
# AUTH_MICROSOFT_CLIENT_SECRET=replace_me_with_azure_client_secret
# AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect
# AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token
# AUTH_GOOGLE_ENABLED=false
# AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id
# AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret
# AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
# AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
# CODE_INTERPRETER_TYPE=LOCAL
# LOGIC_FUNCTION_TYPE=LOCAL
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front
# SUPPORT_FRONT_HMAC_KEY=replace_me_with_front_chat_verification_secret
# SUPPORT_FRONT_CHAT_ID=replace_me_with_front_chat_id
# LOGGER_DRIVER=CONSOLE
# LOGGER_IS_BUFFER_ENABLED=true
# EXCEPTION_HANDLER_DRIVER=sentry
# METER_DRIVER=opentelemetry,console
# SENTRY_ENVIRONMENT=main
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
# SENTRY_FRONT_DSN=https://xxx@xxx.ingest.sentry.io/xxx
# SENTRY_FRONT_TRACES_SAMPLE_RATE=0.1
# SENTRY_TRACES_SAMPLE_RATE=0.1
# SENTRY_PROFILES_SAMPLE_RATE=0.01
# APPLICATION_LOG_DRIVER=CONSOLE
# LOG_LEVELS=error,warn
# SERVER_URL=http://localhost:3000
# Keep above your reverse proxy / load balancer idle timeout (nginx, ALB, ... 60s).
# SERVER_KEEP_ALIVE_TIMEOUT_MS=65000
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=7
# WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION=14
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=21
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email
# IS_EMAIL_VERIFICATION_REQUIRED=false
# EMAIL_VERIFICATION_TOKEN_EXPIRES_IN=1h
# EMAIL_FROM_ADDRESS=contact@yourdomain.com
# EMAIL_FROM_NAME='John from YourDomain'
# EMAIL_DRIVER=LOGGER
# EMAIL_SMTP_HOST=
# EMAIL_SMTP_PORT=
# EMAIL_SMTP_USER=
# EMAIL_SMTP_PASSWORD=
# PASSWORD_RESET_TOKEN_EXPIRES_IN=5m
# CAPTCHA_DRIVER=
# CAPTCHA_SITE_KEY=
# CAPTCHA_SECRET_KEY=
# API_RATE_LIMITING_TTL=
# API_RATE_LIMITING_LIMIT=
# MUTATION_MAXIMUM_AFFECTED_RECORDS=100
# PG_SSL_ALLOW_SELF_SIGNED=true
# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
# SERVER_ID=
# SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt"
# CLOUDFLARE_API_KEY=
# CLOUDFLARE_ZONE_ID=
# CLOUDFLARE_WEBHOOK_SECRET=
# IS_CONFIG_VARIABLES_IN_DB_ENABLED=false
# ANALYTICS_ENABLED=
# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty
# HTTP_TOOL_SAFE_MODE_ENABLED=true
# ALLOW_REQUESTS_TO_TWENTY_ICONS=true
# ———————— ONBOARDING AI CHAT ————————
# Opens an AI chat at the end of onboarding to help set up the workspace.
# IS_ONBOARDING_AI_CHAT_ENABLED=true
# ———————— ENRICHMENT ————————
# Enriches the new workspace with its own company data, used as AI chat context
# and to qualify leads for the book-a-call onboarding step.
# Active as soon as the API key is set.
# PEOPLE_DATA_LABS_API_KEY=
# ———————— BOOK A CALL ————————
# Shows a Cal.com booking step during onboarding to enriched companies with at
# least this many employees. Requires enrichment and CALENDAR_BOOKING_PAGE_ID.
# Both are opt-in: leave the employee count unset to disable the step.
# CALENDAR_BOOKING_PAGE_ID=team/twenty/talk-to-us
# ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT=20
# ———————— AI ————————
# API keys for built-in providers (also editable from Admin Panel > Config Variables):
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=
# XAI_API_KEY=
# GROQ_API_KEY=
# MISTRAL_API_KEY=
#
# Add custom providers (private gateway, extra regions, etc.):
# AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}'
#
# Comma-separated model IDs (JSON array syntax also accepted, e.g. '["a","b"]'):
# AI_MODELS_DEFAULT_EXTRA_FAST=openai/gpt-5.6-luna@low,google/gemini-3.8-flash@low
# AI_MODELS_DEFAULT_FAST=openai/gpt-5.6-luna@medium,google/gemini-3.8-flash@medium
# AI_MODELS_DEFAULT_BALANCED=openai/gpt-5.6-luna@high,google/gemini-3.8-flash@high
# AI_MODELS_DEFAULT_SMART=openai/gpt-5.6-sol@high,google/gemini-3.8-flash@high
# AI_MODELS_DEFAULT_EXTRA_SMART=openai/gpt-6-astra@xhigh,google/gemini-3.8-flash@high
# AI_MODELS_DEFAULT_DISABLED=
# ———————— Emailing domain (marketing campaigns) ————————
# LOG fakes registration, verification and sends locally and never contacts a provider.
# The driver is read from the environment only; setting it in the admin panel has no effect,
# because the SES config below is validated against the environment at boot.
# EMAILING_DOMAIN_DRIVER=LOG
#
# Required when EMAILING_DOMAIN_DRIVER=AWS_SES:
# AWS_SES_REGION=
# AWS_SES_ACCOUNT_ID=
# AWS_SES_ACCESS_KEY_ID=
# AWS_SES_SECRET_ACCESS_KEY=
# Comma-separated SNS topic ARNs allowed to post outbound delivery events:
# SES_SNS_TOPIC_ARN_ALLOWLIST=
# Topic that receives DELIVERY, BOUNCE, COMPLAINT, REJECT and RENDERING_FAILURE events.
# Without it none of them are published and campaign delivery stats stay empty. Bounces
# and complaints also arrive via EventBridge, so those two are delivered twice:
# SES_OUTBOUND_SNS_TOPIC_ARN=
#
# Required when EMAILING_DOMAIN_DRIVER=RESEND:
# RESEND_API_KEY=

View File

@@ -0,0 +1,45 @@
PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/test
REDIS_URL=redis://localhost:6379
APP_SECRET=replace_me_with_a_random_string
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
EXCEPTION_HANDLER_DRIVER=CONSOLE
TELEMETRY_ENABLED=false
SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944
MUTATION_MAXIMUM_RECORD_AFFECTED=100
IS_MULTIWORKSPACE_ENABLED=true
FRONTEND_URL=http://localhost:3001
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false
ENTERPRISE_VALIDITY_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXYtc3Vic2NyaXB0aW9uLWlkIiwic3RhdHVzIjoidmFsaWQiLCJpYXQiOjE3NzMzMDg4MzMsImV4cCI6NDg5NzUxMTIzM30.qhfrW_SV2Y86fWtWXsALlAVhxmMxylUUIefN0fki10Q2NTGGqFVXZrNn2WacJY37yq3m5y4WgwZw34ua6E0ff_YUXsrlY5OHJWHT9DMqKCRn-JujHJnnYp3VHLncy5CvxH5r9mfPFp-5AWe1pYeR1T63sTiejH3sfDrNE357SB7KVti8LCcnsJxEtXB2tRnvyvdun7A-GKoKYEIam-16ZRKKFs6GaWo8ObHdfm8yBt6uK4DZSGPWb644QyWh9FtDxbzJ0ti54DuHSlErLgIp1NNEsMA0MK7zFY7StRaOdt72rxE1ZHwN7e6HhweTU4ORVUPfYkjDFLB2fF7Pa7Kvdg
AUTH_GOOGLE_ENABLED=true
AUTH_GOOGLE_CLIENT_ID=mock-google-client-id
AUTH_GOOGLE_CLIENT_SECRET=mock-google-client-secret
MESSAGING_PROVIDER_GMAIL_ENABLED=true
IS_IMAP_SMTP_CALDAV_ENABLED=true
IS_IMAP_SMTP_CALDAV_CONNECTION_TEST_ENABLED=false
CALENDAR_PROVIDER_GOOGLE_ENABLED=true
AUTH_MICROSOFT_CLIENT_ID=mock-microsoft-client-id
AUTH_MICROSOFT_CLIENT_SECRET=mock-microsoft-client-secret
MESSAGING_PROVIDER_MICROSOFT_ENABLED=true
CALENDAR_PROVIDER_MICROSOFT_ENABLED=true
AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
MESSAGING_PROVIDER_GMAIL_CALLBACK_URL=http://localhost:3000/auth/google-gmail/get-access-token
AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect
AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token
CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty
IS_WORKSPACE_CREATION_V2_ENABLED=true
SHOULD_SEED_STANDARD_RECORD_PAGE_LAYOUTS=true
LOGIC_FUNCTION_TYPE=LOCAL
LOG_LEVELS=log,error,warn
API_RATE_LIMITING_LONG_LIMIT=100000
API_RATE_LIMITING_SHORT_LIMIT=100000

4
packages/twenty-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/*
.local-storage
logs/**/*
*.log

View File

@@ -0,0 +1,158 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"jsPlugins": ["../twenty-oxlint-rules/dist/oxlint-plugin.mjs"],
"options": {
"typeAware": true
},
"categories": {
"correctness": "off"
},
"ignorePatterns": [
"node_modules",
"dist",
".local-storage",
"src/engine/workspace-manager/dev-seeder/data/constants/**",
"src/engine/workspace-manager/dev-seeder/data/seeds/**",
"src/utils/email-providers.ts",
"src/engine/core-modules/i18n/locales/generated/**",
"src/engine/core-modules/logic-function/logic-function-resource/constants/seed-project/src/index.ts",
"src/engine/metadata-modules/front-component/constants/seed-project/**",
"src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts",
"src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts",
"src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts"
],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": [
"warn",
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "twenty-shared/i18n",
"importNames": ["generateMessageId"],
"message": "Provider-owned metadata strings must resolve through resolveEffectiveEntityProperty, which owns the catalog lookup. Hashing message ids by hand is how the per-entity resolvers drifted apart."
},
{
"name": "src/engine/core-modules/i18n/utils/translate-standard-label.util",
"message": "Call resolveEffectiveEntityProperty instead: it arbitrates overrides and overrides.translations before delegating here."
}
],
"patterns": [
{
"group": ["**../"],
"message": "Relative imports are not allowed."
},
{
"group": ["lodash"],
"message": "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)"
}
]
}
],
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": "off",
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "warn",
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"twenty/inject-workspace-repository": "warn",
"twenty/prefer-workspace-scoped-repository": "error",
"twenty/rest-api-methods-should-be-guarded": "error",
"twenty/graphql-resolvers-should-be-guarded": "error",
"twenty/upgrade-command-filename": "error",
"twenty/no-data-mutation-in-fast-instance-command": "error",
"twenty/no-icu-escaping-apostrophe": "error",
"twenty/no-miscased-acronym-in-message": "error",
"twenty/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:backend",
"onlyDependOnLibsWithTags": ["scope:shared", "scope:backend"]
}
]
}
]
},
"overrides": [
{
"files": [
"**/*.spec.ts",
"**/*.integration-spec.ts",
"**/__tests__/**",
"**/test/**"
],
"rules": {
"typescript/no-explicit-any": "off"
}
},
{
"files": ["**/constants/*.ts", "**/*.constants.ts"],
"rules": {
"twenty/max-consts-per-file": "off"
}
},
{
"files": [
"src/engine/core-modules/i18n/utils/translate-standard-label.util.ts",
"src/engine/core-modules/tool-provider/**",
"src/engine/metadata-modules/overrides/utils/resolve-effective-entity-property.util.ts",
"src/engine/metadata-modules/overrides/utils/__tests__/**",
"src/engine/core-modules/i18n/utils/__tests__/**",
"src/engine/workspace-manager/twenty-standard-application/__tests__/**",
"test/**"
],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**../"],
"message": "Relative imports are not allowed."
},
{
"group": ["lodash"],
"message": "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)"
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1,5 @@
**/generated/*
src/engine/metadata-modules/front-component/constants/seed-project/**
src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts
src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts
src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"baseUrl": "./../../",
"experimental": {
"plugins": [
[
"@lingui/swc-plugin",
{
"stripNonEssentialFields": false
}
]
]
}
},
"minify": false
}

View File

@@ -0,0 +1,31 @@
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
declare module 'express-serve-static-core' {
interface Request {
user?: FlatAuthContextUser | null;
apiKey?: FlatApiKey | null;
application?: FlatApplication | null;
userWorkspace?: FlatUserWorkspace;
locale: keyof typeof APP_LOCALES;
workspace?: FlatWorkspace;
workspaceId?: string;
workspaceMetadataVersion?: number;
workspaceMemberId?: string;
workspaceMember?: WorkspaceMemberWorkspaceEntity;
userWorkspaceId?: string;
authProvider?: AuthProviderEnum | null;
impersonationContext?: RawAuthContext['impersonationContext'];
tokenType?: JwtTokenTypeEnum;
authenticatedAt?: Date;
}
}

39
packages/twenty-server/@types/jest.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
import { type INestApplication } from '@nestjs/common';
import 'jest';
import { type DataSource } from 'typeorm';
import { type DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
declare module '@jest/types' {
namespace Config {
interface ConfigGlobals {
APP_PORT: number;
APPLE_JANE_ADMIN_ACCESS_TOKEN: string;
EXPIRED_ACCESS_TOKEN: string;
INVALID_ACCESS_TOKEN: string;
APPLE_JONY_MEMBER_ACCESS_TOKEN: string;
APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
API_KEY_ACCESS_TOKEN: string;
testDataSource?: DataSource;
}
}
}
declare global {
const APP_PORT: number;
const APPLE_JANE_ADMIN_ACCESS_TOKEN: string;
const EXPIRED_ACCESS_TOKEN: string;
const INVALID_ACCESS_TOKEN: string;
const APPLE_JONY_MEMBER_ACCESS_TOKEN: string;
const APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string;
const APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
var testDataSource: DataSource;
var app: INestApplication;
var dataSourceService: DataSourceService;
}
export {};

View File

@@ -0,0 +1,202 @@
# Upgrade Commands
The upgrade process relies on two types of commands:
- **Instance commands** — schema and data migrations that run once at the instance level (replacing raw TypeORM migrations).
- **Workspace commands** — commands that iterate over all active or suspended workspaces to apply per-workspace changes.
Both are registered via decorators and automatically discovered by the upgrade pipeline.
## Instance Commands
### Generating an instance command
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
This generates a timestamped file and auto-registers it in `instance-commands.constant.ts` — do not edit that file manually.
### Fast instance commands
Fast commands run immediately during the upgrade. They are used for schema changes that could introduce breaking inconsistencies between the database and the server if delayed.
A fast command implements `FastInstanceCommand` and provides `up` / `down` methods:
```ts
@RegisteredInstanceCommand('1.22.0', 1775758621017)
export class AddWorkspaceIdToTotoFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."toto" ADD "workspaceId" uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."toto" DROP COLUMN "workspaceId"`,
);
}
}
```
### Slow instance commands
Slow commands are used when a potentially long-running data migration must happen before the schema change. They only run when the `--include-slow` flag is passed.
A slow command implements `SlowInstanceCommand`, which extends `FastInstanceCommand` with an additional `runDataMigration` method that executes before `up`:
```ts
@RegisteredInstanceCommand('1.22.0', 1775758621018, { type: 'slow' })
export class BackfillWorkspaceIdSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
// Backfill logic (can be slow — e.g. iterating over workspaces, cache recomputation)
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."toto" ALTER COLUMN "workspaceId" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."toto" ALTER COLUMN "workspaceId" DROP NOT NULL`,
);
}
}
```
A common pattern is to pair a **fast** command (add a nullable column) with a **slow** command (backfill existing rows, then set `NOT NULL`).
## Workspace Commands
Workspace commands run per-workspace logic across all active or suspended workspaces. They are registered with the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator:
```ts
@RegisteredWorkspaceCommand('1.22.0', 1780000002000)
@Command({
name: 'upgrade:1-22:backfill-standard-skills',
description:
'Backfill standard skills for existing workspaces',
})
export class BackfillStandardSkillsCommand
extends ActiveOrSuspendedWorkspaceCommandRunner
{
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
// inject any services you need
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
// Per-workspace logic goes here
// options.dryRun, options.verbose are available for free
}
}
```
The base class `ActiveOrSuspendedWorkspaceCommandRunner` handles workspace iteration and provides `--dry-run`, `--verbose`, and workspace filter options automatically.
### Applying a migration matrix: side-effect vs legacy path
Commands that build a metadata migration go through `WorkspaceMigrationValidateBuildAndRunService`. Two entry points exist:
- `validateBuildAndRunWorkspaceMigration` (default): runs the operation matrix through the metadata side-effect engine (`expandWithSideEffects`) before building. The engine injects and cascades engine-owned companions (system fields and relations, the `searchVector` field and its GIN index, `searchFieldMetadata` rows, unique backing indexes). This is what the live API and application manifests rely on, so commands that create or mutate custom or application-owned metadata should use it.
- `validateBuildAndRunLegacyWorkspaceMigration`: skips side-effect expansion and applies the matrix literally, exactly as it was authored.
The side-effect engine landed in v2.19. Commands authored before then declared their companions explicitly and were never designed to flow through the engine. Running them through it retroactively changes their behavior: it can hard-fail on reserved-identifier collisions (`RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER`) and silently create rows the command never intended (for example, the deterministic `searchFieldMetadata` rows that the standalone `upgrade:2-16:backfill-search-field-metadata` backfill then re-inserts, hitting `IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE`).
twenty standard metadata never flows through the engine either: `twenty-standard` declares its companions itself and syncs through the FromTo path. An upgrade command that mutates twenty standard entities (`isSystemBuild: true` with the twenty standard application universal identifier, for example updating a standard `commandMenuItem`) must apply its matrix literally with the legacy method whatever its target version, as `upgrade:2-41:move-message-campaign-commands-to-campaign-flag` and `upgrade:2-42:unpin-creation-commands-on-record-selection` do.
Rule of thumb:
- Operations on **twenty standard** metadata, any version → use the **legacy** method.
- Target version **< 2.19** → use the **legacy** method.
- Target version **>= 2.19** on custom or application-owned metadata → use the default side-effect method.
All pre-2.19 commands follow this rule, including `upgrade:2-10:sync-call-recording-standard-objects`: it builds its create-set from the static twenty-standard definition (which declares all of `callRecording`'s fields, including the `searchVector` system field) and runs it through the legacy path so nothing is injected on top. Its matrix contains no `searchFieldMetadata` operations; the deterministic rows are created later in the same upgrade pipeline by `upgrade:2-16:backfill-search-field-metadata`, which derives them from the standard definition.
Known gap: the static definition does not yet declare `callRecording`'s `searchVector` GIN index (every other searchable standard object declares its GIN index statically), so workspaces upgrading through 2-10 on the legacy path create the `searchVector` column unindexed. The static declaration plus a backfill for already-upgraded workspaces land in a follow-up (twentyhq/core-team-issues#2672), which must ship in the same release as this legacy path.
## Execution Order
Within a given version of Twenty, the upgrade pipeline runs commands in this order, sorted by timestamp within each group:
1. **Instance fast** commands
2. **Instance slow** commands
3. **Workspace commands**
Workspace commands are executed sequentially across all active/suspended workspaces.
## Interrupting a run (Ctrl+C, SIGTERM)
Ctrl+C during an `upgrade` stops it gracefully: the workspace being processed finishes its commands, then the run stops instead of starting the next one. Ctrl+C again forces an immediate exit, leaving the command in progress unfinished.
Rerun the command to resume. Nothing is rolled back, and the run picks up from the last command recorded in `upgradeMigration`.
Expect the first Ctrl+C to look like it did nothing while a long step is running: it takes effect once the step ends.
### Running detached
A foreground command dies with the shell that started it, so a dropped `kubectl exec` tunnel, a closed laptop or an expired VPN kills the run. `scripts/command-background.sh` wraps any nest command in its own session with no controlling terminal and streams the output from a log file instead. Use it for long runs (typically `upgrade`) over `kubectl exec` or SSH; foreground is still right locally and in CI.
```bash
yarn command:prod:background <command> [args] # start detached, then stream the log
yarn command:prod:background:logs # re-attach from another shell
yarn command:prod:background:stop # graceful stop; --now immediate, --force SIGKILL
```
`<command> [args]` is forwarded verbatim to `node dist/command/command`, so `yarn command:prod:background upgrade -w <id>` is the detached form of `yarn command:prod upgrade -w <id>`. For `upgrade` the accepted args are `-d/--dry-run`, `-v/--verbose`, `-w/--workspace-id <id>` (repeatable), `--start-from-workspace-id <id>`, `--workspace-count-limit <n>`. The two workspace selectors are mutually exclusive. `--include-slow` is not among them, it belongs to `run-instance-commands`.
Ctrl+C detaches the stream only, and `logs` takes any number of concurrent readers. `logs` reports whether a run is alive before streaming, and on a finished one prints the verdict and the log tail rather than following a log that will never grow again: a segment can run for minutes without printing, so a silent log alone cannot tell a slow step from a dead process.
`stop` has three tiers, three explicit invocations with no timed escalation between them, since a segment can take many minutes and a timer would defeat the graceful path. Each prints the log tail after signalling. Only the node process is signalled, never the process group, which would also kill the wrapper that records the exit code. Graceful handling belongs to the command, not the wrapper: `upgrade` traps `SIGTERM` and stops at its next boundary, but a command without a handler dies immediately with the same exit `143`.
| Invocation | Signal | Effect |
| --- | --- | --- |
| `stop` | one `SIGTERM` | stops at the next boundary, exit `143` |
| `stop --now` | two spaced `SIGTERM`s | immediate exit, step in progress left unfinished |
| `stop --force` | `SIGKILL` | no boundary, a multi-transaction command may leave partial work |
The wrapper outlives node and appends an `EXIT=<code>` line, which is what `logs` translates:
| Log | Meaning |
| --- | --- |
| `EXIT=0` | completed |
| `EXIT=130` / `EXIT=143` | graceful stop on `SIGINT` / `SIGTERM` |
| `EXIT=137` | `SIGKILL`ed, from `stop --force` or an OOM kill |
| any other `EXIT=` | failed |
| no `EXIT=` line | killed without a graceful stop, wrapper included (pod replaced, host lost) |
A graceful stop is always safe to rerun: nothing is rolled back and the run resumes from the last command recorded in `upgradeMigration`, each workspace either fully done with its segment or untouched. Rerunning after a forced kill relies on commands being idempotent.
Limits of this mode:
- Ctrl+C cannot reach a detached run, so `stop` is the only graceful entry point and `130` only ever appears on foreground runs.
- Log and PID files live in `/tmp` in the container (`TWENTY_COMMAND_LOG_FILE`, `TWENTY_COMMAND_PID_FILE`) and are lost with the pod. There is a single PID and log file, so the wrapper runs one background command at a time per machine, enforced by a `flock` on `<pid file>.lock` held for the lifetime of the run.
- The start refusal only sees this container. Nothing in `upgrade` prevents two concurrent sequences either: `upgradeMigration` has no in-progress state and the sequence runner takes no advisory lock. One upgrade at a time is an operational rule, not an enforced one.
- Needs `setsid`, present in the runtime image and on Linux, absent on macOS where `start` refuses and points at the foreground command.
- `terminationGracePeriodSeconds` does not apply. PID 1 in the command-runner pod is `tail -f /dev/null`, so on pod deletion the detached process is torn down without ever receiving `SIGTERM`.
## Shipping a command for a future version (deferred drops)
You can write a command for a version listed in `TWENTY_NEXT_VERSIONS` — typically the second half of a zero-downtime migration, e.g. dropping a column one release after its replacement ships. Pass the target version to the generator:
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type fast --version 2.20.0
```
It registers and boots (versions are validated against `TWENTY_ALL_VERSIONS`) but stays **dormant** — the sequence only runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current). It activates automatically when `nx version:bump` promotes the version to current.
**Caveat:** `@WasRemovedInUpgrade` / `@WasIntroducedInUpgrade` are validated against the active sequence, so a decorator pointing at a still-dormant next-version command fails boot with `unknown-step-name`. For a deferred drop, keep the entity's `WasRemovedInUpgrade<T>` type wrapper now and add the decorator only once the version is current.
See the CI workflows for how upgrade commands are exercised in continuous integration.

View File

@@ -0,0 +1,88 @@
class JestFailuresOnlyReporter {
constructor() {
this._failures = [];
}
onTestResult(_test, testResult) {
if (testResult.numFailingTests === 0 && !testResult.testExecError) {
return;
}
const relativePath = testResult.testFilePath.replace(
process.cwd() + '/',
'',
);
process.stderr.write(`\n\x1b[31mFAIL\x1b[0m ${relativePath}\n`);
if (testResult.failureMessage) {
process.stderr.write(testResult.failureMessage + '\n');
}
if (testResult.testExecError) {
process.stderr.write(
`\n Runtime error: ${testResult.testExecError.message}\n`,
);
}
this._failures.push({ path: relativePath, testResult });
}
onRunComplete(_testContexts, aggregatedResults) {
const {
numPassedTestSuites,
numFailedTestSuites,
numTotalTestSuites,
numPassedTests,
numFailedTests,
numTotalTests,
} = aggregatedResults;
process.stderr.write('\n');
if (this._failures.length > 0) {
process.stderr.write(
`\x1b[31m${'='.repeat(60)}\x1b[0m\n` +
`\x1b[31m FAILED TEST SUITES SUMMARY\x1b[0m\n` +
`\x1b[31m${'='.repeat(60)}\x1b[0m\n\n`,
);
for (const failure of this._failures) {
process.stderr.write(` \x1b[31m✕\x1b[0m ${failure.path}\n`);
const failedTests = failure.testResult.testResults.filter(
(result) => result.status === 'failed',
);
for (const failedTest of failedTests) {
process.stderr.write(` \x1b[31m→\x1b[0m ${failedTest.fullName}\n`);
}
process.stderr.write('\n');
}
process.stderr.write(`\x1b[31m${'='.repeat(60)}\x1b[0m\n\n`);
}
const suiteSummary =
`Test Suites: ` +
(numFailedTestSuites > 0
? `\x1b[31m${numFailedTestSuites} failed\x1b[0m, `
: '') +
(numPassedTestSuites > 0
? `\x1b[32m${numPassedTestSuites} passed\x1b[0m, `
: '') +
`${numTotalTestSuites} total`;
const testSummary =
`Tests: ` +
(numFailedTests > 0 ? `\x1b[31m${numFailedTests} failed\x1b[0m, ` : '') +
(numPassedTests > 0 ? `\x1b[32m${numPassedTests} passed\x1b[0m, ` : '') +
`${numTotalTests} total`;
process.stderr.write(suiteSummary + '\n');
process.stderr.write(testSummary + '\n');
}
}
module.exports = JestFailuresOnlyReporter;

View File

@@ -0,0 +1,19 @@
import { type JestConfigWithTsJest } from 'ts-jest';
import jestIntegrationConfig from './jest-integration.config';
// Secure-deployment integration suite: same harness, but the app boots as a
// production https deployment. SERVER_URL is env-only configuration, so it
// must be set before the app is created in globalSetup; forcing it here keeps
// `nx run twenty-server:test:integration:secure` self-contained.
if (!(process.env.SERVER_URL ?? '').startsWith('https://')) {
process.env.SERVER_URL = 'https://localhost:3000';
}
const jestConfig: JestConfigWithTsJest = {
...jestIntegrationConfig,
testRegex: 'test/integration/secure-deployment/.*\\.integration-spec\\.ts$',
testPathIgnorePatterns: [],
};
export default jestConfig;

View File

@@ -0,0 +1,100 @@
import dotenv from 'dotenv';
import { type JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import testTokens from './test/integration/constants/test-tokens.json';
if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: '.env.test', override: true });
} else {
dotenv.config({ path: '.env', override: true });
}
const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true';
const isClickhouseEnabled = process.env.CLICKHOUSE_URL !== undefined;
const tsConfig = require('./tsconfig.json');
const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
moduleFileExtensions: ['js', 'mjs', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testPathIgnorePatterns: [
...(isBillingEnabled ? [] : ['<rootDir>/test/integration/billing']),
...(isClickhouseEnabled ? [] : ['<rootDir>/test/integration/audit']),
// Requires an app booted as a secure deployment; run through
// jest-integration-secure.config.ts (nx test:integration:secure).
'<rootDir>/test/integration/secure-deployment',
],
testRegex: '\\.integration-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalSetup: '<rootDir>/test/integration/utils/setup-test.ts',
globalTeardown: '<rootDir>/test/integration/utils/teardown-test.ts',
setupFilesAfterEnv: [
'<rootDir>/test/integration/utils/setup-wait-for-all-jobs-between-tests.ts',
],
testTimeout: 20000,
maxWorkers: 1,
// jsdom 29 and msw ship ESM-only transitive deps (parse5, entities,
// tough-cookie, @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp
// css engine, @mswjs/interceptors and friends), and ai, @ai-sdk/* and their
// @workflow/serde dependency ship ESM only; let swc transform them
// (and .mjs below) so jest can require them.
transformIgnorePatterns: [
'/node_modules/(?!(.*/node_modules/)?(jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|msw|@mswjs|until-async|@bundled-es-modules|@open-draft|strict-event-emitter|headers-polyfill|outvariant|is-node-process|path-to-regexp|statuses|cookie|digest-fetch|md5|email-reply-parser|ai|@ai-sdk|@workflow|htmlparser2|domhandler|domutils|dom-serializer|domelementtype|@faker-js)/)',
],
transform: {
'^.+\\.(t|j|mj)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
baseUrl: '.',
paths: {
'src/*': ['./src/*'],
'test/*': ['./test/*'],
},
experimental: {
plugins: [
[
'@lingui/swc-plugin',
{
stripNonEssentialFields: false,
},
],
],
},
},
},
],
},
moduleNameMapper: {
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
'^test/(.*)$': '<rootDir>/test/$1',
},
globals: {
APP_PORT: 4000,
NODE_ENV: NodeEnvironment.TEST,
// Test tokens are loaded from a shared JSON file to ensure consistency
// with CI workflows and other tools that need these tokens
...testTokens,
},
};
export default jestConfig;

View File

@@ -0,0 +1,73 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const isCI = process.env.CI === 'true';
const jestConfig = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
// to enable logs, comment out the following line
silent: true,
...(isCI && { reporters: ['./jest-failures-only-reporter.js'] }),
errorOnDeprecated: true,
clearMocks: true,
displayName: 'twenty-server',
rootDir: './',
testEnvironment: 'node',
setupFilesAfterEnv: ['./setupTests.ts'],
transformIgnorePatterns: [
// jsdom 29 pulls ESM-only transitive deps (parse5, entities, tough-cookie,
// @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp css engine),
// and e2b/@e2b pull ESM-only chalk; ai, @ai-sdk/* and their @workflow/serde dependency ship ESM only.
// sanitize-html >=2.17.6 pulls htmlparser2 12 (ESM-only, with its domhandler/domutils/dom-serializer/domelementtype chain),
// @faker-js/faker 10 ships ESM only (it is a runtime dependency of the OpenAPI module and used by the metadata mocks);
// nested under sanitize-html/node_modules because other consumers hold htmlparser2 8/10 at the root -- the optional
// (.*/node_modules/)? prefix lets the allow-list match a package at any nesting depth, not only the hoisted copy.
// jest's CJS runtime can't load their `export` syntax, so let swc transform them.
'/node_modules/(?!(.*/node_modules/)?(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt|email-reply-parser|jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|graphql-upload|fs-capacitor|e2b|@e2b|chalk|ai|@ai-sdk|@workflow|htmlparser2|domhandler|domutils|dom-serializer|domelementtype|@faker-js)/)',
],
testRegex: '.*\\.spec\\.ts$',
transform: {
// include .mjs so swc transforms ESM-only deps (e.g. jsdom's @csstools/* .mjs)
'^.+\\.(t|j|mj)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
experimental: {
plugins: [
[
'@lingui/swc-plugin',
{
stripNonEssentialFields: false,
},
],
],
},
},
},
],
},
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
'^test/(.*)': '<rootDir>/test/$1',
'^file-type$': require.resolve('file-type'),
},
moduleFileExtensions: ['js', 'mjs', 'json', 'ts'],
modulePathIgnorePatterns: ['<rootDir>/dist'],
fakeTimers: {
enableGlobally: true,
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
};
export default jestConfig;

View File

@@ -0,0 +1,26 @@
import { defineConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
export default defineConfig({
sourceLocale: SOURCE_LOCALE,
locales: Object.values(APP_LOCALES),
pseudoLocale: 'pseudo-en',
fallbackLocales: {
'pseudo-en': 'en',
default: SOURCE_LOCALE,
},
extractorParserOptions: {
tsExperimentalDecorators: true,
},
catalogs: [
{
path: '<rootDir>/src/engine/core-modules/i18n/locales/{locale}',
include: ['src'],
},
],
catalogsMergePath:
'<rootDir>/src/engine/core-modules/i18n/locales/generated/{locale}',
compileNamespace: 'ts',
format: formatter({ lineNumbers: false, printLinguiId: true }),
});

View File

@@ -0,0 +1,71 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"watchOptions": {
"ignored": [
"**/node_modules/**",
"**/dist/**",
"**/.git/**",
"**/.nx/**",
"**/.local-storage/**"
]
},
"compilerOptions": {
"builder": "swc",
"typeCheck": false,
"watchAssets": true,
"assets": [
{
"include": "engine/core-modules/logic-function/logic-function-resource/constants/seed-project/**",
"outDir": "dist/assets"
},
{
"include": "engine/metadata-modules/front-component/constants/seed-project/**",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/application/application-package/constants/seed-dependencies/**",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/application/application-package/constants/yarn-engine/**",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/logic-function/logic-function-drivers/constants/executor/index.mjs",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/logic-function/logic-function-drivers/constants/yarn-install/index.mjs",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/logic-function/logic-function-drivers/constants/builder/index.mjs",
"outDir": "dist/assets"
},
{
"include": "engine/core-modules/logic-function/logic-function-drivers/constants/common-layer-dependencies/**",
"outDir": "dist/assets"
},
{
"include": "database/clickhouse/migrations/*.sql",
"outDir": "dist"
},
{
"include": "engine/workspace-manager/dev-seeder/data/sample-files/**",
"outDir": "dist/assets"
},
{
"include": "engine/metadata-modules/ai/ai-models/ai-providers.json",
"outDir": "dist"
},
{
"include": "engine/core-modules/dpa/constants/subprocessors.json",
"outDir": "dist"
}
]
},
"monorepo": false,
"root": "src"
}

View File

@@ -0,0 +1,243 @@
{
"name": "twenty-server",
"description": "",
"author": "",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-server node ../../node_modules/nx/bin/nx.js",
"start:prod": "node dist/main",
"command:prod": "node dist/command/command",
"worker:prod": "node dist/queue-worker/queue-worker",
"database:init:prod": "node dist/database/scripts/setup-db.js && yarn database:migrate:prod --force --include-slow",
"database:migrate:prod": "node dist/command/command run-instance-commands",
"clickhouse:migrate:prod": "node dist/database/clickhouse/migrations/run-migrations.js",
"command:prod:background": "./scripts/command-background.sh start",
"command:prod:background:logs": "./scripts/command-background.sh logs",
"command:prod:background:stop": "./scripts/command-background.sh stop",
"typeorm": "../../node_modules/typeorm/.bin/typeorm"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.76",
"@ai-sdk/anthropic": "^4.0.49",
"@ai-sdk/azure": "^4.0.63",
"@ai-sdk/google": "^4.0.64",
"@ai-sdk/mistral": "^4.0.39",
"@ai-sdk/openai": "^4.0.60",
"@ai-sdk/openai-compatible": "^3.0.44",
"@ai-sdk/provider-utils": "^5.0.36",
"@ai-sdk/xai": "^4.0.54",
"@aws-sdk/client-lambda": "3.1001.0",
"@aws-sdk/client-s3": "3.1001.0",
"@aws-sdk/client-sesv2": "3.1001.0",
"@aws-sdk/client-sts": "3.1001.0",
"@aws-sdk/credential-providers": "3.1001.0",
"@aws-sdk/lib-storage": "3.1001.0",
"@aws-sdk/s3-request-presigner": "3.1001.0",
"@azure/msal-node": "^5.2.3",
"@blocknote/server-util": "^0.51.4",
"@clickhouse/client": "^1.18.1",
"@e2b/code-interpreter": "^2.6.0",
"@envelop/core": "4.0.3",
"@faker-js/faker": "10.5.0",
"@file-type/pdf": "^0.3.0",
"@graphql-tools/schema": "10.0.4",
"@graphql-tools/utils": "9.2.1",
"@graphql-yoga/nestjs": "2.1.0",
"@jrmdayn/googleapis-batcher": "^0.10.1",
"@lifeomic/axios-fetch": "^3.1.0",
"@lingui/conf": "5.9.5",
"@lingui/core": "^5.9.5",
"@lingui/format-po": "5.9.5",
"@microsoft/microsoft-graph-client": "3.0.7",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/axios": "3.1.2",
"@nestjs/cache-manager": "^2.3.0",
"@nestjs/common": "11.2.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.2.1",
"@nestjs/event-emitter": "2.1.0",
"@nestjs/graphql": "patch:@nestjs/graphql@npm%3A13.4.5#~/.yarn/patches/@nestjs-graphql-npm-13.4.5-2bf8ea7c63.patch",
"@nestjs/jwt": "11.0.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.2.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/serve-static": "5.0.5",
"@nestjs/terminus": "11.0.0",
"@nestjs/typeorm": "11.0.0",
"@node-saml/node-saml": "5.1.0",
"@node-saml/passport-saml": "^5.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.219.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@react-pdf/renderer": "^4.1.6",
"@sentry/nestjs": "^10.73.0",
"@sentry/node": "^10.73.0",
"@sentry/profiling-node": "^10.73.0",
"@sniptt/guards": "0.2.0",
"addressparser": "1.0.1",
"ai": "7.0.93",
"archiver": "7.0.1",
"axios": "^1.16.0",
"axios-retry": "^4.5.0",
"bcrypt": "^6.0.0",
"bullmq": "5.78.0",
"bytes": "3.1.2",
"cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2",
"chalk": "4.1.2",
"class-transformer": "0.5.1",
"class-validator": "0.14.0",
"cloudflare": "^4.5.0",
"connect-redis": "^7.1.1",
"cron-parser": "5.1.1",
"dataloader": "2.2.2",
"date-fns": "4.4.0",
"digest-fetch": "^3.1.1",
"dompurify": "^3.4.11",
"dotenv": "16.4.5",
"email-reply-parser": "^2.3.5",
"express": "5.2.1",
"express-session": "^1.18.2",
"file-type": "^21.3.2",
"fuse.js": "^7.1.0",
"gaxios": "7.1.5",
"googleapis": "173.0.0",
"graphql": "16.8.1",
"graphql-fields": "2.0.3",
"graphql-redis-subscriptions": "2.7.0",
"graphql-scalars": "1.23.0",
"graphql-subscriptions": "2.0.0",
"graphql-tag": "2.12.6",
"graphql-type-json": "0.3.2",
"graphql-upload": "16.0.2",
"graphql-yoga": "4.0.5",
"html-to-text": "^9.0.5",
"ical-generator": "^11.0.0",
"imapflow": "2.0.2",
"ioredis": "5.10.1",
"jsdom": "^30.0.1",
"json-schema": "0.4.0",
"jsonwebtoken": "9.0.2",
"libphonenumber-js": "1.11.5",
"lodash.camelcase": "4.3.0",
"lodash.chunk": "4.2.0",
"lodash.compact": "3.0.1",
"lodash.differencewith": "^4.5.0",
"lodash.groupby": "4.6.0",
"lodash.isempty": "4.4.0",
"lodash.isequal": "4.5.0",
"lodash.isobject": "3.0.2",
"lodash.kebabcase": "4.1.1",
"lodash.merge": "^4.6.2",
"lodash.omit": "4.5.0",
"lodash.snakecase": "4.1.1",
"lodash.uniq": "^4.5.0",
"lodash.uniqby": "^4.7.0",
"microdiff": "1.4.0",
"mrmime": "^2.0.1",
"ms": "2.1.3",
"nest-commander": "^3.19.1",
"node-ical": "^0.21.0",
"nodemailer": "^9.0.1",
"openapi-types": "12.1.3",
"openid-client": "^5.7.0",
"otplib": "^12.0.1",
"passport": "^0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"passport-microsoft": "2.1.0",
"path-to-regexp": "^8.4.0",
"pg": "8.12.0",
"pluralize": "8.0.0",
"postal-mime": "^2.6.1",
"psl": "^1.9.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"redis": "^4.7.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"sanitize-html": "2.17.7",
"semver": "7.6.3",
"sns-payload-validator": "^2.1.0",
"stripe": "20.4.1",
"tar": "^7.5.9",
"temporal-polyfill": "^0.3.0",
"tsconfig-paths": "^4.2.0",
"tsdav": "^2.2.1",
"tslib": "2.8.1",
"type-fest": "4.10.1",
"typeorm": "patch:typeorm@0.3.31#./patches/typeorm+0.3.31.patch",
"unzipper": "^0.12.3",
"uuid": "^11.1.1",
"zod": "^4.1.11"
},
"devDependencies": {
"@lingui/cli": "^5.9.5",
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "11.2.1",
"@smithy/util-stream": "4.5.16",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/addressparser": "^1.0.3",
"@types/archiver": "^6.0.0",
"@types/aws-lambda": "^8.10.147",
"@types/bcrypt": "^5.0.0",
"@types/bytes": "^3.1.1",
"@types/express": "^4.17.13",
"@types/express-session": "^1.18.0",
"@types/graphql-fields": "^1.3.6",
"@types/graphql-upload": "^16.0.7",
"@types/html-to-text": "^9.0.4",
"@types/jest": "^30.0.0",
"@types/lodash.chunk": "^4.2.9",
"@types/lodash.compact": "^3.0.9",
"@types/lodash.differencewith": "^4.5.9",
"@types/lodash.groupby": "^4.6.9",
"@types/lodash.isempty": "^4.4.7",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.isobject": "^3.0.7",
"@types/lodash.merge": "^4.6.9",
"@types/lodash.omit": "^4.5.9",
"@types/lodash.snakecase": "^4.1.7",
"@types/lodash.uniq": "^4.5.9",
"@types/lodash.uniqby": "^4.7.9",
"@types/ms": "^0.7.31",
"@types/node": "^24.0.0",
"@types/node-fetch": "^2.6.12",
"@types/nodemailer": "^7.0.3",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "^2.1.0",
"@types/pg": "^8.15.6",
"@types/pluralize": "^0.0.33",
"@types/psl": "^1.1.3",
"@types/react": "^19.2.0",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^2.0.11",
"@types/unzipper": "^0",
"@types/uuid": "^9.0.2",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"msw": "2.12.14",
"node-fetch": "^2.7.0",
"prettier": "^3.1.1",
"rimraf": "^5.0.5",
"supertest": "^6.1.3",
"testcontainers": "12.1.0",
"ts-jest": "^29.1.1",
"ts-node": "10.9.1",
"twenty-client-sdk": "workspace:*",
"twenty-emails": "workspace:*",
"twenty-shared": "workspace:*"
},
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": "^4.0.2"
}
}

View File

@@ -0,0 +1,28 @@
# How to patch a dependency
`yarn patch-commit -s` does not work in our monorepo. Use the workflow below instead.
## New patch
```bash
yarn patch <package-name>
# Yarn prints a temp folder path — edit files there, then:
yarn patch-commit <temp-folder> > packages/twenty-server/patches/<package+name+version>.patch
yarn install --mode update-lockfile && yarn install
```
Reference the patch in `packages/twenty-server/package.json`:
```
"<package-name>": "patch:<package-name>@<version>#./patches/<package+name+version>.patch"
```
## Updating an existing patch
```bash
yarn patch -u <package-name> # extract with current patches applied (PATCHED)
yarn patch <package-name> # extract clean original (CLEAN)
# Edit files in PATCHED, then copy them into CLEAN
yarn patch-commit <CLEAN> > packages/twenty-server/patches/<package+name+version>.patch
yarn install --mode update-lockfile && yarn install
```

View File

@@ -0,0 +1,46 @@
diff --git a/common/PickKeysByType.d.ts b/common/PickKeysByType.d.ts
index 55ad347..4288c06 100644
--- a/common/PickKeysByType.d.ts
+++ b/common/PickKeysByType.d.ts
@@ -1,6 +1,6 @@
/**
* Pick only the keys that match the Type `U`
*/
-export type PickKeysByType<T, U> = string & keyof {
- [P in keyof T as T[P] extends U ? P : never]: T[P];
-};
+export type PickKeysByType<T, U> = string & {
+ [P in keyof T]: Exclude<T[P], null> extends U ? P : never;
+}[keyof T];
diff --git a/query-builder/result/DeleteResult.d.ts b/query-builder/result/DeleteResult.d.ts
index 9c98830..d0578f9 100644
--- a/query-builder/result/DeleteResult.d.ts
+++ b/query-builder/result/DeleteResult.d.ts
@@ -13,4 +13,9 @@ export declare class DeleteResult {
* Not all drivers support this
*/
affected?: number | null;
+ /**
+ * Generated values returned by a database.
+ * Has entity-like structure (not just column database name and values).
+ */
+ generatedMaps: ObjectLiteral[];
}
diff --git a/query-builder/result/DeleteResult.js b/query-builder/result/DeleteResult.js
index 6519c11..0bb344a 100644
--- a/query-builder/result/DeleteResult.js
+++ b/query-builder/result/DeleteResult.js
@@ -5,6 +5,13 @@ exports.DeleteResult = void 0;
* Result object returned by DeleteQueryBuilder execution.
*/
class DeleteResult {
+ constructor() {
+ /**
+ * Generated values returned by a database.
+ * Has entity-like structure (not just column database name and values).
+ */
+ this.generatedMaps = [];
+ }
static from(queryResult) {
const result = new this();
result.raw = queryResult.records;

View File

@@ -0,0 +1,323 @@
{
"name": "twenty-server",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:backend"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-server",
"parallel": false,
"commands": [
"rimraf dist",
"nest build --path ./tsconfig.build.json",
"mkdir -p dist/assets/twenty-client-sdk && cp ../twenty-client-sdk/package.json dist/assets/twenty-client-sdk/ && cp -r ../twenty-client-sdk/dist dist/assets/twenty-client-sdk/dist"
]
},
"dependsOn": ["^build"]
},
"test:integration:secure": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration-secure.config.ts --logHeapUsage"
]
},
"parallel": false
},
"test:integration": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration.config.ts --logHeapUsage"
]
},
"parallel": false,
"configurations": {
"with-db-reset": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=12288 --import tsx/esm\" nx database:reset > reset-logs.log && NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration.config.ts --logHeapUsage"
]
}
}
},
"test:ci": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "jest --config ./jest.config.mjs --ci --runInBand"
}
},
"build:packageJson": {
"executor": "@nx/js:tsc",
"options": {
"main": "packages/twenty-server/dist/main.js",
"tsConfig": "packages/twenty-server/tsconfig.json",
"outputPath": "packages/twenty-server/dist",
"updateBuildableProjectDepsInPackageJson": true
}
},
"typecheck": {
"dependsOn": ["^build"]
},
"start": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "rimraf dist && NODE_ENV=development nest start --watch"
}
},
"start:ci": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "NODE_ENV=development nest start"
}
},
"start:ci-if-needed": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "curl -f -s http://localhost:3000/healthz > /dev/null 2>&1 && echo '✅ Server already running' || (echo '🚀 Server not running, starting...' && nohup nest start &)"
}
},
"start:debug": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "rimraf dist && NODE_ENV=development nest start --watch --debug"
}
},
"reset:env": {
"executor": "nx:run-commands",
"inputs": ["{projectRoot}/.env.example"],
"outputs": ["{projectRoot}/.env"],
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "cp .env.example .env"
}
},
"reset:env:e2e-testing-server": {
"executor": "nx:run-commands",
"inputs": ["{projectRoot}/.env.e2e-testing-server"],
"outputs": ["{projectRoot}/.env"],
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "cp .env.e2e-testing-server .env"
}
},
"command": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js"
}
},
"command-no-deps": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js"
}
},
"worker": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "NODE_ENV=development nest start --watch --entryFile queue-worker/queue-worker"
}
},
"ts-node": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node"
}
},
"ts-node-no-deps": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node"
}
},
"ts-node-no-deps-transpile-only": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node --transpile-only"
}
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["twenty-oxlint-rules:build"],
"inputs": [
"{projectRoot}/src/**/*.{ts,tsx}",
"{projectRoot}/tsconfig*.json",
"{workspaceRoot}/tsconfig.base.json",
"{projectRoot}/.oxlintrc.json",
"{workspaceRoot}/packages/twenty-oxlint-rules/dist/oxlint-plugin.mjs"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint --type-aware -c .oxlintrc.json src/ && (npx oxfmt --check src/ || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint twenty-server --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --type-aware --fix -c .oxlintrc.json src/ && npx oxfmt src/"
}
}
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"cwd": "{projectRoot}",
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware -c .oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main twenty-server --configuration=fix' && false)))"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware --fix -c .oxlintrc.json $FILES && npx oxfmt $FILES)"
}
}
},
"test": {
"options": {
"maxWorkers": "50%"
}
},
"test:debug": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand"
}
},
"jest": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "jest"
}
},
"database:migrate": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js run-instance-commands --force"
}
},
"database:init": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/setup-db.js",
"nx database:migrate -- --include-slow"
],
"parallel": false
}
},
"database:migrate:generate": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js generate:instance-command"
},
"cache": false
},
"generate:integration-test": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"nx ts-node-no-deps -- ./test/integration/graphql/codegen/index.ts"
],
"parallel": false
}
},
"database:reset": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"configurations": {
"no-seed": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/truncate-db.js",
"nx database:init",
"nx command-no-deps -- cache:flush"
],
"parallel": false
},
"seed": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/truncate-db.js",
"nx database:init",
"nx command-no-deps -- cache:flush",
"nx command-no-deps -- workspace:seed:dev"
],
"parallel": false
}
},
"defaultConfiguration": "seed"
},
"clickhouse:migrate": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "nx ts-node-no-deps-transpile-only -- src/database/clickhouse/migrations/run-migrations.ts"
}
},
"clickhouse:seed": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "nx ts-node-no-deps-transpile-only -- src/database/clickhouse/seeds/run-seeds.ts"
}
},
"lingui:extract": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui extract --overwrite --clean"
}
},
"lingui:compile": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui compile --typescript"
}
},
"version:bump": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "npx tsx scripts/bump-version.ts"
}
}
}
}

View File

@@ -0,0 +1,93 @@
# AI catalog
Three files in `src/engine/metadata-modules/ai/ai-models`, and two entry points
over them.
| File | What it is |
| --- | --- |
| `ai-models.json` | What every model is: identity, pricing, limits, modalities, efforts, benchmarks. No routes, no credentials. Synced daily. |
| `ai-self-host-spec.json` | What a self-hosted deployment serves: the five direct routes and their key templates. Hand-maintained. |
| `ai-providers.json` | The catalog the server bundles. Generated from the two above; a test fails if it is hand-edited. |
Cloud works the same way, from a private spec in twenty-infra, so a deployment
catalog is always a projection rather than a second copy of the truth.
## `index.ts` — sync the model catalog
Run daily by `.github/workflows/ci-ai-catalog-sync.yaml`. Reads models.dev for
model identity, pricing, context windows and availability, overlays Artificial
Analysis for intelligence, speed and cost per task, and writes `ai-models.json`
and `ai-model-benchmarks.json`, then projects the self-host spec over the first
to write `ai-providers.json`. Hand-maintained fields (`efforts`,
`dataResidency`, `zeroDataRetention`) survive the rebuild.
```bash
npx nx run twenty-server:ts-node-no-deps-transpile-only -- \
./scripts/ai-catalog-sync/index.ts --dry-run
```
## `project.ts` — derive a deployment catalog
A deployment serves a subset of the catalog through its own routes: Azure, a
Bedrock region, a gateway, or a first-party key. It declares only what is its
own — which routes exist, their credentials, and which catalog models each one
serves — and this projects the catalog through that spec.
```bash
npx tsx ./scripts/ai-catalog-sync/project.ts \
--spec ./my-deployment.json \
--out ./ai-catalog.json
```
`--catalog` points at a different `ai-models.json`; it defaults to the
committed one. The output has the same shape, so a server loads it through
`AI_CATALOG_STORAGE_PATH` with no further processing.
A spec looks like this:
```json
{
"providers": [
{
"name": "azure-foundry",
"npm": "@ai-sdk/azure",
"label": "Azure AI Foundry",
"apiKey": "{{AZURE_FOUNDRY_API_KEY}}",
"baseUrl": "{{AZURE_FOUNDRY_BASE_URL}}",
"dataResidency": "eu",
"labelSuffix": " (Azure)",
"models": [
"gpt-5.6-luna",
{ "model": "gpt-5.6-sol", "cachedInputCostPerMillionTokens": 0.5 }
]
},
{
"name": "amazon-bedrock",
"npm": "@ai-sdk/amazon-bedrock",
"region": "eu-central-1",
"models": [
{ "model": "claude-opus-4-7", "as": "eu.anthropic.claude-opus-4-7" }
]
}
]
}
```
A route that serves a whole vendor says so instead of listing its models, which
is how self-host picks up new models with the daily sync:
```json
{ "name": "openai", "npm": "@ai-sdk/openai", "models": { "vendor": "openai" } }
```
A spec can rename a model for the route that deploys it (`as`) and override the
prices it negotiated, the limits its route caps, its label and its deprecation.
It cannot restate what a model is: modalities, reasoning support, efforts and
benchmarks always come from the catalog, which is what keeps a deployment from drifting away from the
measured truth. Naming a model the catalog does not carry fails the run rather
than publishing a route to nothing.
`project.ts` resolves everything it needs by source path, so a repository
holding a private spec can run it from a sparse checkout of these files and
`packages/twenty-shared/src/ai` without installing the monorepo and without any
API key.

View File

@@ -0,0 +1,63 @@
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { assertPayloadIsUsable } from '../utils/assert-payload-is-usable.util';
import { buildCatalog } from '../utils/build-catalog.util';
const languageModel = {
id: 'model',
name: 'Model',
tool_call: true,
cost: { input: 2, output: 6 },
limit: { context: 128000, output: 8192 },
};
const VENDORS = ['openai', 'anthropic', 'google', 'xai', 'mistral'];
const payload = (): ModelsDevData => ({
openai: { id: 'openai', models: { 'gpt-x': languageModel } },
anthropic: { id: 'anthropic', models: { 'claude-x': languageModel } },
google: { id: 'google', models: { 'gemini-x': languageModel } },
xai: { id: 'xai', models: { 'grok-x': languageModel } },
mistral: { id: 'mistral', models: { 'mistral-x': languageModel } },
});
describe('assertPayloadIsUsable', () => {
it('accepts a payload carrying models for every vendor we ship', () => {
expect(() =>
assertPayloadIsUsable({ data: payload(), vendors: VENDORS }),
).not.toThrow();
});
it('refuses an empty payload rather than generating an empty catalog', () => {
expect(() =>
assertPayloadIsUsable({ data: {} as ModelsDevData, vendors: VENDORS }),
).toThrow(/refusing to overwrite the catalog/);
});
it('names the providers that came back empty', () => {
const data = payload();
data.anthropic.models = {};
expect(() => assertPayloadIsUsable({ data, vendors: VENDORS })).toThrow(
/anthropic/,
);
});
});
describe('buildCatalog', () => {
it('prices long context from the standard output rate when none is quoted', () => {
const data = payload();
data.openai.models['gpt-x'].cost = {
input: 2,
output: 6,
context_over_200k: { input: 4 },
};
const model = buildCatalog({ data, vendors: VENDORS }).openai.models[0];
// A missing long-context rate is unknown, not free.
expect(model.longContextCost?.outputCostPerMillionTokens).toBe(6);
});
});

View File

@@ -0,0 +1,54 @@
import { type GeneratedCatalog } from '../types/generated-catalog.type';
import { type GeneratedModel } from '../types/generated-model.type';
import { carryOverCommittedFields } from '../utils/carry-over-committed-fields.util';
const openai = (models: GeneratedModel[]): GeneratedCatalog => ({
openai: { models },
});
describe('carryOverCommittedFields', () => {
it('restores the fields models.dev does not carry, and only those', () => {
const catalog = openai([
{ name: 'gpt-x', label: 'GPT X', supportsReasoning: true },
]);
carryOverCommittedFields({
catalog,
committedCatalog: openai([
{
name: 'gpt-x',
label: 'Old label',
inputCostPerMillionTokens: 1,
efforts: ['low', 'high'],
dataResidency: 'us',
zeroDataRetention: true,
},
]),
});
expect(catalog.openai.models[0]).toEqual({
name: 'gpt-x',
label: 'GPT X',
supportsReasoning: true,
efforts: ['low', 'high'],
dataResidency: 'us',
zeroDataRetention: true,
});
});
it('leaves a model the committed catalog does not know untouched', () => {
const catalog = openai([{ name: 'gpt-new', label: 'GPT New' }]);
carryOverCommittedFields({
catalog,
committedCatalog: openai([
{ name: 'gpt-x', label: 'GPT X', efforts: ['low'] },
]),
});
expect(catalog.openai.models[0]).toEqual({
name: 'gpt-new',
label: 'GPT New',
});
});
});

View File

@@ -0,0 +1,131 @@
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { enrichCatalog } from '../utils/enrich-catalog.util';
import { type BenchmarkIndex } from '../types/benchmark-index.type';
import { type GeneratedCatalog } from '../types/generated-catalog.type';
const MEASURED_AT = '2026-09-09';
const catalogOf = (...modelNames: string[]): GeneratedCatalog => ({
openai: { models: modelNames.map((name) => ({ name, label: name })) },
});
const enrich = (catalog: GeneratedCatalog, benchmarkIndex: BenchmarkIndex) =>
enrichCatalog({
catalog,
modelsDevData: {} as ModelsDevData,
benchmarkIndex,
measuredAt: MEASURED_AT,
});
describe('enrichCatalog', () => {
it('attaches a measurement to the model and mirrors it into the overlay', () => {
const catalog = catalogOf('gpt-x');
const overlay = enrich(
catalog,
new Map([['gptx', { intelligenceIndex: 41, aliases: ['gpt-x'] }]]),
);
expect(catalog.openai.models[0].benchmark).toEqual({
intelligenceIndex: 41,
outputTokensPerSecond: undefined,
costPerTask: undefined,
measuredAt: MEASURED_AT,
});
expect(overlay['gpt-x'].measuredAt).toBe(MEASURED_AT);
});
it('leaves a row the publisher measured nothing about out of both artifacts', () => {
const catalog = catalogOf('gpt-x');
const overlay = enrich(
catalog,
new Map([['gptx', { aliases: ['gpt-x'] }]]),
);
expect(overlay).toEqual({});
expect(catalog.openai.models[0].benchmark).toBeUndefined();
});
it('leaves a model no publisher covers untouched and out of the overlay', () => {
const catalog = catalogOf('gpt-x');
const overlay = enrich(catalog, new Map());
expect(catalog.openai.models[0].benchmark).toBeUndefined();
expect(overlay).toEqual({});
});
it('keeps isDeprecated last so the generated catalog stays stable', () => {
const catalog = catalogOf('gpt-x');
catalog.openai.models[0].isDeprecated = true;
enrich(
catalog,
new Map([['gptx', { intelligenceIndex: 41, aliases: ['gpt-x'] }]]),
);
expect(Object.keys(catalog.openai.models[0])).toEqual([
'name',
'label',
'benchmark',
'isDeprecated',
]);
});
it('embeds per-effort readings in the catalog and mirrors them, with aliases, into the overlay', () => {
const catalog = catalogOf('gpt-x');
catalog.openai.models[0].efforts = ['low', 'high'];
const overlay = enrich(
catalog,
new Map([
[
'gptx',
{ intelligenceIndex: 41, effort: 'high', aliases: ['GPT X (high)'] },
],
[
'gptx@high',
{ intelligenceIndex: 41, effort: 'high', aliases: ['GPT X (high)'] },
],
[
'gptx@low',
{ intelligenceIndex: 30, effort: 'low', aliases: ['GPT X (low)'] },
],
]),
);
expect(catalog.openai.models[0].benchmark?.effort).toBe('high');
expect(catalog.openai.models[0].benchmarkByEffort).toEqual({
low: { intelligenceIndex: 30, effort: 'low', measuredAt: MEASURED_AT },
high: { intelligenceIndex: 41, effort: 'high', measuredAt: MEASURED_AT },
});
expect(overlay['gpt-x'].benchmarkByEffort?.low).toEqual({
intelligenceIndex: 30,
effort: 'low',
measuredAt: MEASURED_AT,
aliases: ['GPT X (low)'],
});
expect(Object.keys(catalog.openai.models[0])).toEqual([
'name',
'label',
'efforts',
'benchmark',
'benchmarkByEffort',
]);
});
it('writes no effort map for a model whose declared efforts have no rows', () => {
const catalog = catalogOf('gpt-x');
catalog.openai.models[0].efforts = ['low', 'high'];
const overlay = enrich(
catalog,
new Map([['gptx', { intelligenceIndex: 41, aliases: ['gpt-x'] }]]),
);
expect(catalog.openai.models[0].benchmarkByEffort).toBeUndefined();
expect(overlay['gpt-x'].benchmarkByEffort).toBeUndefined();
});
});

View File

@@ -0,0 +1,246 @@
import { fetchArtificialAnalysisBenchmarks } from '../utils/fetch-artificial-analysis-benchmarks.util';
const respondWith = (models: unknown[]): void => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: models }),
}) as unknown as typeof fetch;
};
const measuredModel = {
slug: 'claude-sonnet-5',
evaluations: { artificial_analysis_intelligence_index: 38.4 },
performance: { median_output_tokens_per_second: 92.1 },
artificial_analysis_intelligence_index_cost: {
cost_per_task: { total_cost: 0.42 },
},
};
describe('fetchArtificialAnalysisBenchmarks', () => {
it('reads the index, speed and cost per task for a measured model', async () => {
respondWith([measuredModel]);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'claudesonnet5',
);
expect(record?.intelligenceIndex).toBe(38.4);
expect(record?.outputTokensPerSecond).toBe(92.1);
expect(record?.costPerTask).toBe(0.42);
});
it('treats zero as not measured rather than as a measurement', async () => {
respondWith([
{
slug: 'grok-4-6',
evaluations: { artificial_analysis_intelligence_index: 44.1 },
performance: { median_output_tokens_per_second: 0 },
},
]);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'grok46',
);
expect(record?.intelligenceIndex).toBe(44.1);
expect(record?.outputTokensPerSecond).toBeUndefined();
});
it.each([
['best row first', true],
['best row last', false],
])(
'keeps the highest-scoring configuration whatever the order (%s)',
async (_label, bestFirst) => {
// The rows for one model are the same model at different reasoning
// efforts. Picking by how populated a row is scored Sonnet 4.6 at low
// effort against Sonnet 5 at max, and the gap read as capability.
const lowEffort = {
slug: 'claude-sonnet-4-6',
name: 'Claude Sonnet 4.6 (Non-reasoning, Low Effort)',
evaluations: { artificial_analysis_intelligence_index: 23.3 },
performance: { median_output_tokens_per_second: 120 },
artificial_analysis_intelligence_index_cost: {
cost_per_task: { total_cost: 0.1 },
},
};
const maxEffort = {
slug: 'claude-sonnet-4-6',
name: 'Claude Sonnet 4.6 (Adaptive Reasoning, Max Effort)',
evaluations: { artificial_analysis_intelligence_index: 36.2 },
performance: { median_output_tokens_per_second: 80 },
};
respondWith(bestFirst ? [maxEffort, lowEffort] : [lowEffort, maxEffort]);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'claudesonnet46',
);
expect(record?.intelligenceIndex).toBe(36.2);
// The whole row travels together, so speed and cost describe the same
// configuration the index was measured in.
expect(record?.outputTokensPerSecond).toBe(80);
expect(record?.costPerTask).toBeUndefined();
},
);
it.each([
['unmeasured row first', true],
['measured row first', false],
])(
'keeps the measured row when an unmeasured duplicate shares its name (%s)',
async (_label, unmeasuredFirst) => {
const unmeasured = {
slug: 'claude-sonnet-5',
performance: { median_output_tokens_per_second: 0 },
};
respondWith(
unmeasuredFirst
? [unmeasured, measuredModel]
: [measuredModel, unmeasured],
);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'claudesonnet5',
);
expect(record?.intelligenceIndex).toBe(38.4);
},
);
it('survives a null row rather than discarding the whole response', async () => {
respondWith([null, measuredModel]);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'claudesonnet5',
);
expect(record?.intelligenceIndex).toBe(38.4);
});
it('fails loudly when every row came back unmeasured', async () => {
respondWith([
{
slug: 'claude-sonnet-5',
performance: { median_output_tokens_per_second: 0 },
},
]);
await expect(fetchArtificialAnalysisBenchmarks('key')).rejects.toThrow(
/none carried a measurement/,
);
});
it('ignores figures published at the top level instead of their container', async () => {
respondWith([
{
slug: 'claude-sonnet-5',
artificial_analysis_intelligence_index: 38.4,
performance: { median_output_tokens_per_second: 92.1 },
},
]);
const record = (await fetchArtificialAnalysisBenchmarks('key')).get(
'claudesonnet5',
);
expect(record?.intelligenceIndex).toBeUndefined();
expect(record?.outputTokensPerSecond).toBe(92.1);
});
it('reads every page of the list before indexing', async () => {
const pages = [
{
data: [measuredModel],
pagination: { page: 1, has_more: true },
},
{
data: [
{
slug: 'gpt-5-6-luna',
evaluations: { artificial_analysis_intelligence_index: 37.5 },
},
],
pagination: { page: 2, has_more: false },
},
];
const fetchMock = jest.fn(async (url: URL) => ({
ok: true,
json: async () => pages[Number(url.searchParams.get('page')) - 1],
}));
global.fetch = fetchMock as unknown as typeof fetch;
const index = await fetchArtificialAnalysisBenchmarks('key');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(index.get('claudesonnet5')?.intelligenceIndex).toBe(38.4);
expect(index.get('gpt56luna')?.intelligenceIndex).toBe(37.5);
});
it('files a row under its effort as well as under the bare model name', async () => {
respondWith([
{
slug: 'gpt-5-6-sol',
name: 'GPT-5.6 Sol (max)',
evaluations: { artificial_analysis_intelligence_index: 47.1 },
},
{
slug: 'gpt-5-6-sol',
name: 'GPT-5.6 Sol (low)',
evaluations: { artificial_analysis_intelligence_index: 30.2 },
artificial_analysis_intelligence_index_cost: {
cost_per_task: { total_cost: 0.08 },
},
},
]);
const index = await fetchArtificialAnalysisBenchmarks('key');
// The bare key still carries the ceiling, so nothing a consumer reads today
// changes.
expect(index.get('gpt56sol')?.intelligenceIndex).toBe(47.1);
expect(index.get('gpt56sol')?.effort).toBe('max');
expect(index.get('gpt56sol@max')?.intelligenceIndex).toBe(47.1);
expect(index.get('gpt56sol@low')?.intelligenceIndex).toBe(30.2);
expect(index.get('gpt56sol@low')?.costPerTask).toBe(0.08);
expect(index.get('gpt56sol@low')?.effort).toBe('low');
});
it('keeps the better-measured row when two rows share an effort', async () => {
respondWith([
{
slug: 'grok-4-6',
name: 'Grok 4.6 (high)',
evaluations: { artificial_analysis_intelligence_index: 44.4 },
},
{
slug: 'grok-4-6',
name: 'Grok 4.6 (high)',
evaluations: { artificial_analysis_intelligence_index: 44.4 },
performance: { median_output_tokens_per_second: 71 },
},
]);
const index = await fetchArtificialAnalysisBenchmarks('key');
expect(index.get('grok46@high')?.outputTokensPerSecond).toBe(71);
});
it('files a row naming no effort under the bare name only', async () => {
respondWith([
{
slug: 'gemini-3-1-pro-preview',
name: 'Gemini 3.1 Pro Preview',
evaluations: { artificial_analysis_intelligence_index: 30.4 },
},
]);
const index = await fetchArtificialAnalysisBenchmarks('key');
expect(index.get('gemini31propreview')?.effort).toBeUndefined();
expect([...index.keys()].some((key) => key.includes('@'))).toBe(false);
});
});

View File

@@ -0,0 +1,339 @@
import { type ModelsDevModel } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-model.type';
import { type BenchmarkIndex } from '../types/benchmark-index.type';
import { type BenchmarkRecord } from '../types/benchmark-record.type';
import { buildLookupCandidates } from '../utils/build-lookup-candidates.util';
import { matchBenchmarks } from '../utils/match-benchmarks.util';
const MEASURED_AT = '2026-09-09';
const modelsDevModel = (
overrides: Partial<ModelsDevModel> = {},
): ModelsDevModel => ({
id: 'model',
name: 'Model',
cost: { input: 2, output: 6 },
limit: { context: 128000, output: 8192 },
...overrides,
});
const MISTRAL_MODELS: Record<string, ModelsDevModel> = {
'mistral-large-latest': modelsDevModel({ id: 'mistral-large-latest' }),
'mistral-large-2512': modelsDevModel({
id: 'mistral-large-2512',
release_date: '2025-12-01',
}),
'mistral-large-2411': modelsDevModel({
id: 'mistral-large-2411',
release_date: '2024-11-01',
cost: { input: 3, output: 9 },
}),
};
const indexOf = (entries: Record<string, BenchmarkRecord>): BenchmarkIndex =>
new Map(Object.entries(entries));
const match = (modelName: string, benchmarkIndex: BenchmarkIndex) =>
matchBenchmarks({
modelName,
siblingModels: MISTRAL_MODELS,
benchmarkIndex,
measuredAt: MEASURED_AT,
});
describe('buildLookupCandidates', () => {
it('resolves a rolling alias to the priced-identical release it points at', () => {
expect(
buildLookupCandidates({
modelName: 'mistral-large-latest',
siblingModels: MISTRAL_MODELS,
}),
).toContain('mistral-large-2512');
});
it('prefers the newest release when several share the same price and limits', () => {
const candidates = buildLookupCandidates({
modelName: 'mistral-large-latest',
siblingModels: {
...MISTRAL_MODELS,
'mistral-large-2506': modelsDevModel({ release_date: '2025-06-01' }),
},
});
expect(candidates).toContain('mistral-large-2512');
expect(candidates).not.toContain('mistral-large-2506');
});
it('resolves the same way whatever order an undated provider lists twins in', () => {
// Nothing in models.dev hits this today, but the resolution must not depend
// on the order a third party happens to serialize its models in.
const undated = {
'mistral-large-latest': modelsDevModel({ id: 'mistral-large-latest' }),
'mistral-large-2506': modelsDevModel({ id: 'mistral-large-2506' }),
'mistral-large-2512': modelsDevModel({ id: 'mistral-large-2512' }),
};
const resolve = (siblingModels: Record<string, ModelsDevModel>) =>
buildLookupCandidates({
modelName: 'mistral-large-latest',
siblingModels,
});
const reordered = {
'mistral-large-latest': undated['mistral-large-latest'],
'mistral-large-2512': undated['mistral-large-2512'],
'mistral-large-2506': undated['mistral-large-2506'],
};
[undated, reordered].forEach((siblingModels) => {
const candidates = resolve(siblingModels);
expect(candidates).toContain('mistral-large-2512');
expect(candidates).not.toContain('mistral-large-2506');
});
});
it('does not offer the undated name once the alias resolves to a release', () => {
// The leaderboard's bare `mistral-large` row is the Feb '24 model. Letting
// it stand in for the release `-latest` currently points at published a
// two-year-old score as the current one.
const candidates = buildLookupCandidates({
modelName: 'mistral-large-latest',
siblingModels: MISTRAL_MODELS,
});
expect(candidates).toContain('mistral-large-2512');
expect(candidates).not.toContain('mistral-large');
});
it('offers no undated spelling of a resolved full-date release either', () => {
// The publisher's undated row is whichever snapshot it last measured, which
// is the same trap as the bare rolling name in a longer date format.
const candidates = buildLookupCandidates({
modelName: 'claude-sonnet-5-latest',
siblingModels: {
'claude-sonnet-5-latest': modelsDevModel({
id: 'claude-sonnet-5-latest',
}),
'claude-sonnet-5-20260101': modelsDevModel({
id: 'claude-sonnet-5-20260101',
release_date: '2026-01-01',
}),
},
});
expect(candidates).toContain('claude-sonnet-5-20260101');
expect(candidates).not.toContain('claude-sonnet-5');
});
it('falls back to the undated name when no release can be resolved', () => {
const candidates = buildLookupCandidates({
modelName: 'mistral-large-latest',
siblingModels: {
'mistral-large-latest': modelsDevModel({ id: 'mistral-large-latest' }),
},
});
expect(candidates).toContain('mistral-large');
});
it('offers the undated name for a dated snapshot', () => {
expect(
buildLookupCandidates({
modelName: 'claude-sonnet-4-5-20250929',
siblingModels: {},
}),
).toContain('claude-sonnet-4-5');
});
});
describe('matchBenchmarks', () => {
it('scores a rolling alias from the release it resolves to', () => {
const result = match(
'mistral-large-latest',
indexOf({ mistrallarge2512: { intelligenceIndex: 41, aliases: [] } }),
);
expect(result?.benchmark.intelligenceIndex).toBe(41);
expect(result?.benchmark.measuredAt).toBe(MEASURED_AT);
});
it('carries speed and cost per task through to the catalog', () => {
const result = match(
'mistral-large-2512',
indexOf({
mistrallarge2512: {
outputTokensPerSecond: 92,
costPerTask: 0.42,
aliases: [],
},
}),
);
expect(result?.benchmark.outputTokensPerSecond).toBe(92);
expect(result?.benchmark.costPerTask).toBe(0.42);
});
it('returns nothing for a model absent from the index', () => {
expect(match('mistral-large-2512', new Map())).toBeUndefined();
});
it('returns nothing for a matched row carrying no measurement at all', () => {
// The publisher lists models it has measured nothing about; matching one
// must not produce an entry holding only a timestamp.
expect(
match(
'mistral-large-2512',
indexOf({ mistrallarge2512: { aliases: ['mistral-large-2512'] } }),
),
).toBeUndefined();
});
it('prefers the release a rolling alias resolves to over an undated row', () => {
const result = match(
'mistral-large-latest',
indexOf({
mistrallarge: { intelligenceIndex: 20, aliases: [] },
mistrallarge2512: { intelligenceIndex: 41, aliases: [] },
}),
);
expect(result?.benchmark.intelligenceIndex).toBe(41);
});
it('collects the aliases the publisher gives so consumers can join on them', () => {
const result = match(
'mistral-large-2512',
indexOf({
mistrallarge2512: {
intelligenceIndex: 41,
aliases: ['mistral-large-2512', 'mistralai/mistral-large-2512'],
},
}),
);
expect(result?.aliases).toEqual([
'mistral-large-2512',
'mistralai/mistral-large-2512',
]);
});
it('reads each declared effort from the row taken at that effort', () => {
const result = matchBenchmarks({
modelName: 'mistral-large-2512',
siblingModels: MISTRAL_MODELS,
benchmarkIndex: indexOf({
mistrallarge2512: {
intelligenceIndex: 41,
effort: 'high',
aliases: ['Mistral Large 3 (high)'],
},
'mistrallarge2512@high': {
intelligenceIndex: 41,
effort: 'high',
aliases: ['Mistral Large 3 (high)'],
},
'mistrallarge2512@none': {
intelligenceIndex: 22,
costPerTask: 0.1,
effort: 'none',
aliases: ['Mistral Large 3 (Non-reasoning)'],
},
}),
measuredAt: MEASURED_AT,
efforts: ['none', 'high'],
});
expect(result?.benchmark.effort).toBe('high');
expect(result?.benchmarkByEffort?.none).toEqual({
intelligenceIndex: 22,
costPerTask: 0.1,
effort: 'none',
measuredAt: MEASURED_AT,
aliases: ['Mistral Large 3 (Non-reasoning)'],
});
expect(result?.benchmarkByEffort?.high?.intelligenceIndex).toBe(41);
});
it('leaves a declared effort without its own row blank rather than lending it the ceiling', () => {
const result = matchBenchmarks({
modelName: 'mistral-large-2512',
siblingModels: MISTRAL_MODELS,
benchmarkIndex: indexOf({
mistrallarge2512: {
intelligenceIndex: 41,
effort: 'high',
aliases: [],
},
'mistrallarge2512@high': {
intelligenceIndex: 41,
effort: 'high',
aliases: [],
},
}),
measuredAt: MEASURED_AT,
efforts: ['none', 'high'],
});
expect(result?.benchmarkByEffort?.none).toBeUndefined();
expect(result?.benchmarkByEffort?.high?.intelligenceIndex).toBe(41);
});
it('carries no effort map at all when no declared effort has a row', () => {
const result = matchBenchmarks({
modelName: 'mistral-large-2512',
siblingModels: MISTRAL_MODELS,
benchmarkIndex: indexOf({
mistrallarge2512: { intelligenceIndex: 41, aliases: [] },
}),
measuredAt: MEASURED_AT,
efforts: ['none', 'high'],
});
expect(result?.benchmark.intelligenceIndex).toBe(41);
expect(result?.benchmarkByEffort).toBeUndefined();
});
it('resolves a rolling alias to its release for per-effort rows too', () => {
const result = matchBenchmarks({
modelName: 'mistral-large-latest',
siblingModels: MISTRAL_MODELS,
benchmarkIndex: indexOf({
mistrallarge2512: { intelligenceIndex: 41, aliases: [] },
'mistrallarge2512@high': {
intelligenceIndex: 41,
effort: 'high',
aliases: [],
},
'mistrallarge@high': {
intelligenceIndex: 20,
effort: 'high',
aliases: [],
},
}),
measuredAt: MEASURED_AT,
efforts: ['high'],
});
expect(result?.benchmarkByEffort?.high?.intelligenceIndex).toBe(41);
});
it('labels a per-effort reading with the key it was filed under', () => {
const result = matchBenchmarks({
modelName: 'mistral-large-2512',
siblingModels: MISTRAL_MODELS,
benchmarkIndex: indexOf({
mistrallarge2512: { intelligenceIndex: 41, aliases: [] },
'mistrallarge2512@low': {
intelligenceIndex: 30,
effort: 'high',
aliases: [],
},
}),
measuredAt: MEASURED_AT,
efforts: ['low'],
});
expect(result?.benchmarkByEffort?.low?.effort).toBe('low');
});
});

View File

@@ -0,0 +1,30 @@
import { parseArtificialAnalysisEffort } from '../utils/parse-artificial-analysis-effort.util';
describe('parseArtificialAnalysisEffort', () => {
it.each([
['GPT-5.6 Sol (max)', 'max'],
['GPT-5.4 (xhigh)', 'xhigh'],
['Grok 4.6 (high)', 'high'],
['Gemini 3.5 Flash (medium)', 'medium'],
['GPT-5 nano (minimal)', 'minimal'],
['Claude Opus 5 (Adaptive Reasoning, Max Effort)', 'max'],
[
'Claude Fable 5 (Adaptive Reasoning, Max Effort, Opus 4.8 Fallback)',
'max',
],
['Claude Sonnet 4.6 (Non-reasoning, Low Effort)', 'low'],
['GPT-5.6 Luna (Non-reasoning)', 'none'],
])('reads "%s" as %s', (displayName, effort) => {
expect(parseArtificialAnalysisEffort(displayName)).toBe(effort);
});
it.each([
'Claude Opus 4.5 (Reasoning)',
'Gemini 3.1 Pro Preview',
'GPT-4o (March 2025, chatgpt-4o-latest)',
"Llama 3.1 405B (May '24)",
'Mistral Large 3 (high) Turbo',
])('names no effort for "%s"', (displayName) => {
expect(parseArtificialAnalysisEffort(displayName)).toBeUndefined();
});
});

View File

@@ -0,0 +1,292 @@
import { type CatalogSpec } from '../types/catalog-spec.type';
import {
type CanonicalCatalog,
projectCatalog,
} from '../utils/project-catalog.util';
const canonicalCatalog: CanonicalCatalog = {
openai: {
npm: '@ai-sdk/openai',
label: 'OpenAI',
apiKey: '{{OPENAI_API_KEY}}',
models: [
{
name: 'gpt-5.6-luna',
label: 'GPT-5.6 Luna',
inputCostPerMillionTokens: 0.2,
outputCostPerMillionTokens: 1.2,
cachedInputCostPerMillionTokens: 0.02,
modalities: ['image', 'pdf'],
efforts: ['low', 'medium', 'high'],
benchmark: { intelligenceIndex: 37.5, measuredAt: '2026-09-11' },
benchmarkByEffort: {
high: { intelligenceIndex: 32.4, measuredAt: '2026-09-11' },
},
},
],
},
anthropic: {
npm: '@ai-sdk/anthropic',
label: 'Anthropic',
apiKey: '{{ANTHROPIC_API_KEY}}',
models: [{ name: 'claude-opus-4-7', label: 'Claude Opus 4.7' }],
},
};
describe('projectCatalog', () => {
it('describes a route from the catalog while keeping the route its own credentials', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
label: 'Azure AI Foundry',
apiKey: '{{AZURE_FOUNDRY_API_KEY}}',
baseUrl: '{{AZURE_FOUNDRY_BASE_URL}}',
dataResidency: 'eu',
labelSuffix: ' (Azure)',
models: ['gpt-5.6-luna'],
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(Object.keys(projected)).toEqual(['azure-foundry']);
expect(projected['azure-foundry']).toMatchObject({
npm: '@ai-sdk/azure',
label: 'Azure AI Foundry',
apiKey: '{{AZURE_FOUNDRY_API_KEY}}',
baseUrl: '{{AZURE_FOUNDRY_BASE_URL}}',
});
expect(projected['azure-foundry'].models?.[0]).toEqual({
name: 'gpt-5.6-luna',
label: 'GPT-5.6 Luna (Azure)',
inputCostPerMillionTokens: 0.2,
outputCostPerMillionTokens: 1.2,
cachedInputCostPerMillionTokens: 0.02,
modalities: ['image', 'pdf'],
efforts: ['low', 'medium', 'high'],
benchmark: { intelligenceIndex: 37.5, measuredAt: '2026-09-11' },
benchmarkByEffort: {
high: { intelligenceIndex: 32.4, measuredAt: '2026-09-11' },
},
dataResidency: 'eu',
});
});
it('serves a model under the id the route deploys it as', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'amazon-bedrock',
npm: '@ai-sdk/amazon-bedrock',
region: 'eu-central-1',
models: [
{ model: 'claude-opus-4-7', as: 'eu.anthropic.claude-opus-4-7' },
],
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['amazon-bedrock'].models?.[0]).toMatchObject({
name: 'eu.anthropic.claude-opus-4-7',
label: 'Claude Opus 4.7',
});
expect(projected['amazon-bedrock'].region).toBe('eu-central-1');
});
it('takes a price the route negotiated and leaves the rest of the reading alone', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: [
{ model: 'gpt-5.6-luna', cachedInputCostPerMillionTokens: 0.5 },
],
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['azure-foundry'].models?.[0]).toMatchObject({
cachedInputCostPerMillionTokens: 0.5,
inputCostPerMillionTokens: 0.2,
efforts: ['low', 'medium', 'high'],
});
});
it('takes a limit the route caps below the one the model allows', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'amazon-bedrock',
npm: '@ai-sdk/amazon-bedrock',
models: [{ model: 'gpt-5.6-luna', maxOutputTokens: 64000 }],
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['amazon-bedrock'].models?.[0]).toMatchObject({
maxOutputTokens: 64000,
});
});
it('serves every model a vendor publishes when the spec names the vendor', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'openai',
npm: '@ai-sdk/openai',
apiKey: '{{OPENAI_API_KEY}}',
models: { vendor: 'openai' },
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['openai'].models?.map((model) => model.name)).toEqual([
'gpt-5.6-luna',
]);
expect(projected['openai'].models?.[0]).toMatchObject({
efforts: ['low', 'medium', 'high'],
});
});
it('refuses a spec naming a vendor the catalog does not carry', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'openai',
npm: '@ai-sdk/openai',
models: { vendor: 'openai-imaginary' },
},
],
};
expect(() => projectCatalog({ canonicalCatalog, spec })).toThrow(
'which the catalog does not carry',
);
});
it('refuses a spec naming a model the catalog does not carry', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: ['gpt-5.6-luna', 'gpt-9-imaginary'],
},
],
};
expect(() => projectCatalog({ canonicalCatalog, spec })).toThrow(
'azure-foundry/gpt-9-imaginary',
);
});
it('cannot restate what a model is, only which models a route serves', () => {
const spec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: [{ model: 'gpt-5.6-luna', modalities: ['image'] }],
},
],
} as unknown as CatalogSpec;
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['azure-foundry'].models?.[0]).toMatchObject({
modalities: ['image', 'pdf'],
});
});
it('refuses a repeated provider, which would drop the first one silently', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: ['gpt-5.6-luna'],
},
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: ['claude-opus-4-7'],
},
],
};
expect(() => projectCatalog({ canonicalCatalog, spec })).toThrow(
'repeated or reserved',
);
});
it('refuses an SDK package or residency the server would not parse', () => {
const withNpm = (npm: string): CatalogSpec => ({
providers: [{ name: 'gateway', npm, models: ['gpt-5.6-luna'] }],
});
expect(() =>
projectCatalog({ canonicalCatalog, spec: withNpm('@ai-sdk/imaginary') }),
).toThrow('unsupported SDK package');
expect(() =>
projectCatalog({
canonicalCatalog,
spec: {
providers: [
{
name: 'gateway',
npm: '@ai-sdk/openai',
dataResidency: 'mars',
models: ['gpt-5.6-luna'],
},
],
},
}),
).toThrow('unsupported data residency');
});
it('refuses a negotiated price that is not a usable number', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
models: [{ model: 'gpt-5.6-luna', inputCostPerMillionTokens: -1 }],
},
],
};
expect(() => projectCatalog({ canonicalCatalog, spec })).toThrow(
'which is not a usable number',
);
});
it('marks the route on a label the spec overrides', () => {
const spec: CatalogSpec = {
providers: [
{
name: 'azure-foundry',
npm: '@ai-sdk/azure',
labelSuffix: ' (Azure)',
models: [{ model: 'gpt-5.6-luna', label: 'Luna' }],
},
],
};
const projected = projectCatalog({ canonicalCatalog, spec });
expect(projected['azure-foundry'].models?.[0]?.label).toBe('Luna (Azure)');
});
});

View File

@@ -0,0 +1,142 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { matchBenchmarks } from '../utils/match-benchmarks.util';
import { readCommittedBenchmarks } from '../utils/read-committed-benchmarks.util';
const writeOverlay = (value: unknown): string => {
const filePath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-')),
'ai-model-benchmarks.json',
);
fs.writeFileSync(filePath, JSON.stringify(value), 'utf-8');
return filePath;
};
const PUBLISHED = {
source: 'artificialanalysis.ai',
measuredAt: '2026-09-01',
models: {
'claude-sonnet-5': {
intelligenceIndex: 38.4,
outputTokensPerSecond: 92.1,
measuredAt: '2026-09-01',
aliases: ['claude-sonnet-5', 'claude-sonnet-5-20260630'],
},
},
};
describe('readCommittedBenchmarks', () => {
it('returns nothing when no overlay has been published yet', () => {
expect(
readCommittedBenchmarks(path.join(os.tmpdir(), 'does-not-exist.json'))
.size,
).toBe(0);
});
it('recovers every measurement', () => {
const index = readCommittedBenchmarks(writeOverlay(PUBLISHED));
const record = index.get('claudesonnet5');
expect(record?.intelligenceIndex).toBe(38.4);
expect(record?.outputTokensPerSecond).toBe(92.1);
});
it('indexes the published aliases so matching still resolves them', () => {
const index = readCommittedBenchmarks(writeOverlay(PUBLISHED));
expect(index.get('claudesonnet520260630')?.intelligenceIndex).toBe(38.4);
});
it('keeps a preserved measurement dated when it was taken, not today', () => {
// Restamping recovered data as measured today would present a stale
// benchmark as fresh on every failed run.
const index = readCommittedBenchmarks(writeOverlay(PUBLISHED));
const result = matchBenchmarks({
modelName: 'claude-sonnet-5',
siblingModels: {},
benchmarkIndex: index,
measuredAt: '2026-09-09',
});
expect(result?.benchmark.measuredAt).toBe('2026-09-01');
});
it('recovers per-effort readings under their effort keys, dated when they were taken', () => {
const index = readCommittedBenchmarks(
writeOverlay({
...PUBLISHED,
models: {
'claude-sonnet-5': {
...PUBLISHED.models['claude-sonnet-5'],
effort: 'max',
benchmarkByEffort: {
low: {
intelligenceIndex: 20.1,
measuredAt: '2026-09-01',
aliases: ['Claude Sonnet 5 (low)'],
},
},
},
},
}),
);
expect(index.get('claudesonnet5')?.effort).toBe('max');
expect(index.get('claudesonnet5@low')?.intelligenceIndex).toBe(20.1);
expect(index.get('claudesonnet5@low')?.effort).toBe('low');
expect(index.get('claudesonnet5@low')?.measuredAt).toBe('2026-09-01');
expect(index.get('claudesonnet5@max')).toBeUndefined();
const result = matchBenchmarks({
modelName: 'claude-sonnet-5',
siblingModels: {},
benchmarkIndex: index,
measuredAt: '2026-09-09',
efforts: ['low', 'max'],
});
expect(result?.benchmarkByEffort?.low?.measuredAt).toBe('2026-09-01');
expect(result?.benchmarkByEffort?.max).toBeUndefined();
});
it('ignores a per-effort key it does not recognise', () => {
const index = readCommittedBenchmarks(
writeOverlay({
...PUBLISHED,
models: {
'claude-sonnet-5': {
...PUBLISHED.models['claude-sonnet-5'],
benchmarkByEffort: {
turbo: { intelligenceIndex: 99, aliases: [] },
},
},
},
}),
);
expect([...index.keys()].some((key) => key.includes('@'))).toBe(false);
});
it('trusts the key over a nested reading that names another effort', () => {
const index = readCommittedBenchmarks(
writeOverlay({
...PUBLISHED,
models: {
'claude-sonnet-5': {
...PUBLISHED.models['claude-sonnet-5'],
benchmarkByEffort: {
low: { intelligenceIndex: 20.1, effort: 'high', aliases: [] },
},
},
},
}),
);
expect(index.get('claudesonnet5@low')?.effort).toBe('low');
});
});

View File

@@ -0,0 +1,22 @@
import canonicalCatalog from 'src/engine/metadata-modules/ai/ai-models/ai-models.json';
import shippedCatalog from 'src/engine/metadata-modules/ai/ai-models/ai-providers.json';
import selfHostSpec from 'src/engine/metadata-modules/ai/ai-models/ai-self-host-spec.json';
import { type CatalogSpec } from '../types/catalog-spec.type';
import {
type CanonicalCatalog,
projectCatalog,
} from '../utils/project-catalog.util';
// The shipped catalog is generated, and a hand edit to it is the drift this
// pipeline exists to remove, so it has to stay what its spec produces.
describe('the shipped catalog', () => {
it('is what the self-host spec projects from the model catalog', () => {
const projected = projectCatalog({
canonicalCatalog: canonicalCatalog as CanonicalCatalog,
spec: selfHostSpec as CatalogSpec,
});
expect(projected).toEqual(shippedCatalog);
});
});

View File

@@ -0,0 +1,211 @@
import * as fs from 'fs';
import * as path from 'path';
import * as prettier from 'prettier';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { MODELS_DEV_API_URL } from 'src/engine/metadata-modules/ai/ai-models/constants/models-dev.const';
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { assertPayloadIsUsable } from './utils/assert-payload-is-usable.util';
import { buildCatalog } from './utils/build-catalog.util';
import { carryOverCommittedFields } from './utils/carry-over-committed-fields.util';
import { enrichCatalog } from './utils/enrich-catalog.util';
import { fetchArtificialAnalysisBenchmarks } from './utils/fetch-artificial-analysis-benchmarks.util';
import { projectCatalog } from './utils/project-catalog.util';
import { readCommittedBenchmarks } from './utils/read-committed-benchmarks.util';
import { buildCoverageReport } from './utils/build-coverage-report.util';
import { renderCoverageReport } from './utils/render-coverage-report.util';
import { type BenchmarkIndex } from './types/benchmark-index.type';
import { type CatalogSpec } from './types/catalog-spec.type';
import { type GeneratedCatalog } from './types/generated-catalog.type';
const AI_MODELS_DIR = path.resolve(
__dirname,
'..',
'..',
'src',
'engine',
'metadata-modules',
'ai',
'ai-models',
);
const MODELS_PATH = path.join(AI_MODELS_DIR, 'ai-models.json');
const SELF_HOST_SPEC_PATH = path.join(AI_MODELS_DIR, 'ai-self-host-spec.json');
const CATALOG_PATH = path.join(AI_MODELS_DIR, 'ai-providers.json');
const BENCHMARKS_PATH = path.join(AI_MODELS_DIR, 'ai-model-benchmarks.json');
// oxlint-disable no-console
const log = console.log;
const warn = console.warn;
// oxlint-enable no-console
const readArgument = (flag: string): string | undefined => {
const index = process.argv.indexOf(flag);
return index === -1 ? undefined : process.argv[index + 1];
};
const readCommittedModels = (filePath: string): GeneratedCatalog =>
fs.existsSync(filePath)
? (JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GeneratedCatalog)
: {};
// The vendors the shipped catalog carries are the ones the self-host spec
// serves; nothing else needs to be fetched, checked or written.
const readVendors = (spec: CatalogSpec): string[] => {
const vendors = [
...new Set(
spec.providers.flatMap((provider) =>
Array.isArray(provider.models) ? [] : [provider.models.vendor],
),
),
];
// An empty list would fetch nothing, assert nothing and write an empty
// catalog over the real one, and the sync PR automerges.
if (!isNonEmptyArray(vendors)) {
throw new Error(
`${SELF_HOST_SPEC_PATH} names no vendor to carry: every route lists models explicitly`,
);
}
return vendors;
};
const fetchModelsDev = async ({
vendors,
}: {
vendors: string[];
}): Promise<ModelsDevData> => {
const response = await fetch(MODELS_DEV_API_URL, {
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const data: ModelsDevData = await response.json();
assertPayloadIsUsable({ data, vendors });
return data;
};
// A leaderboard outage must never break the model catalog, so a failed fetch
// degrades to an empty index and the catalog is written without benchmarks.
const fetchBenchmarks = async (): Promise<BenchmarkIndex> => {
const apiKey = process.env.ARTIFICIAL_ANALYSIS_API_KEY;
if (!isDefined(apiKey) || apiKey.length === 0) {
warn('Skipping benchmarks: ARTIFICIAL_ANALYSIS_API_KEY is not set');
return new Map();
}
try {
const index = await fetchArtificialAnalysisBenchmarks(apiKey);
log(`Fetched ${index.size} model aliases from Artificial Analysis`);
return index;
} catch (error) {
warn(
`Skipping benchmarks: ${error instanceof Error ? error.message : String(error)}`,
);
return new Map();
}
};
const writeJson = async (filePath: string, value: unknown): Promise<void> => {
const prettierConfig = await prettier.resolveConfig(filePath);
const formatted = await prettier.format(
JSON.stringify(value, null, 2) + '\n',
{ ...prettierConfig, filepath: filePath },
);
fs.writeFileSync(filePath, formatted, 'utf-8');
log(`Wrote ${filePath}`);
};
const main = async (): Promise<void> => {
const dryRun = process.argv.includes('--dry-run');
const reportPath = readArgument('--report');
const measuredAt = new Date().toISOString().slice(0, 10);
const selfHostSpec = JSON.parse(
fs.readFileSync(SELF_HOST_SPEC_PATH, 'utf-8'),
) as CatalogSpec;
const vendors = readVendors(selfHostSpec);
log('Fetching models.dev API...');
const modelsDevData = await fetchModelsDev({ vendors });
log(`Fetched ${Object.keys(modelsDevData).length} providers from models.dev`);
const fetched = await fetchBenchmarks();
// A failed fetch must not delete measurements we already published: the
// catalog PR is automerged, so an empty index would silently strip every
// benchmark until the next healthy run.
const benchmarkIndex =
fetched.size > 0 ? fetched : readCommittedBenchmarks(BENCHMARKS_PATH);
if (fetched.size === 0 && benchmarkIndex.size > 0) {
warn(
`Reusing ${benchmarkIndex.size} committed benchmark aliases from a previous run`,
);
}
const catalog = buildCatalog({ data: modelsDevData, vendors });
carryOverCommittedFields({
catalog,
committedCatalog: readCommittedModels(MODELS_PATH),
});
const overlay = enrichCatalog({
catalog,
modelsDevData,
benchmarkIndex,
measuredAt,
});
const renderedReport = renderCoverageReport(buildCoverageReport(catalog));
log(`\n${renderedReport}\n`);
if (isDefined(reportPath)) {
fs.writeFileSync(reportPath, `${renderedReport}\n`, 'utf-8');
}
if (dryRun) {
log('[DRY RUN] Skipping writes');
return;
}
await writeJson(MODELS_PATH, catalog);
// Self-host runs the same projection cloud does, from a spec that names the
// five direct routes and lets each serve its whole vendor.
await writeJson(
CATALOG_PATH,
projectCatalog({ canonicalCatalog: catalog, spec: selfHostSpec }),
);
await writeJson(BENCHMARKS_PATH, {
source: 'artificialanalysis.ai',
measuredAt,
models: overlay,
});
};
main().catch((error) => {
// oxlint-disable-next-line no-console
console.error('AI catalog sync failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,134 @@
// Projects the canonical catalog through a deployment spec. See README.md.
//
// This entry point and the files it imports resolve through node builtins and
// source paths only, so a repository holding a private spec can run them
// straight from a sparse checkout without installing the monorepo.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { type CatalogSpec } from './types/catalog-spec.type';
import {
type CanonicalCatalog,
projectCatalog,
} from './utils/project-catalog.util';
const DEFAULT_CATALOG_PATH = resolve(
__dirname,
'..',
'..',
'src',
'engine',
'metadata-modules',
'ai',
'ai-models',
'ai-models.json',
);
// A flag whose value is missing would otherwise swallow the next flag and write
// the catalog to a file named after it.
const readArgument = (flag: string): string | undefined => {
const index = process.argv.indexOf(flag);
if (index === -1) {
return undefined;
}
const value = process.argv[index + 1];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} is missing its value`);
}
return value;
};
const readJson = <TValue>(path: string): TValue => {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as TValue;
} catch (error) {
throw new Error(
`Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const assertSpecIsUsable = ({
spec,
path,
}: {
spec: CatalogSpec;
path: string;
}): void => {
if (!Array.isArray(spec.providers) || spec.providers.length === 0) {
throw new Error(`${path} declares no providers`);
}
for (const provider of spec.providers) {
if (typeof provider.name !== 'string' || provider.name.length === 0) {
throw new Error(`${path} has a provider without a name`);
}
if (typeof provider.npm !== 'string' || provider.npm.length === 0) {
throw new Error(
`${path}: provider "${provider.name}" has no npm package`,
);
}
const servesModels = Array.isArray(provider.models)
? provider.models.length > 0
: typeof provider.models?.vendor === 'string' &&
provider.models.vendor.length > 0;
if (!servesModels) {
throw new Error(`${path}: provider "${provider.name}" serves no models`);
}
}
};
const describe = (catalog: CanonicalCatalog): string => {
const models = Object.values(catalog).flatMap(
(provider) => provider.models ?? [],
);
const withEfforts = models.filter((model) => model.efforts !== undefined);
const withBenchmark = models.filter((model) => model.benchmark !== undefined);
return `${Object.keys(catalog).length} providers, ${models.length} models, ${withEfforts.length} with efforts, ${withBenchmark.length} with a benchmark`;
};
const main = (): void => {
const specPath = readArgument('--spec');
const outPath = readArgument('--out');
if (specPath === undefined || outPath === undefined) {
throw new Error(
'Usage: project.ts --spec <spec.json> --out <catalog.json> [--catalog <ai-models.json>]',
);
}
const catalogPath = readArgument('--catalog') ?? DEFAULT_CATALOG_PATH;
const spec = readJson<CatalogSpec>(specPath);
assertSpecIsUsable({ spec, path: specPath });
const projected = projectCatalog({
canonicalCatalog: readJson<CanonicalCatalog>(catalogPath),
spec,
});
mkdirSync(dirname(resolve(outPath)), { recursive: true });
writeFileSync(outPath, `${JSON.stringify(projected, null, 2)}\n`, 'utf-8');
// oxlint-disable-next-line no-console
console.log(`Wrote ${outPath}: ${describe(projected)}`);
};
try {
main();
} catch (error) {
// oxlint-disable-next-line no-console
console.error(
`AI catalog projection failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}

View File

@@ -0,0 +1,5 @@
import { type BenchmarkRecord } from './benchmark-record.type';
// Keyed by normalized model name, so a single lookup resolves every alias a
// source publishes for the same underlying model.
export type BenchmarkIndex = Map<string, BenchmarkRecord>;

View File

@@ -0,0 +1,11 @@
import { type AiModelEffort } from 'twenty-shared/ai';
import { type AiModelBenchmark } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-benchmark.type';
import { type BenchmarkOverlayReading } from './benchmark-overlay-entry.type';
export type BenchmarkMatch = {
benchmark: AiModelBenchmark;
aliases: string[];
benchmarkByEffort?: Partial<Record<AiModelEffort, BenchmarkOverlayReading>>;
};

View File

@@ -0,0 +1,16 @@
import { type AiModelEffort } from 'twenty-shared/ai';
import { type AiModelBenchmark } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-benchmark.type';
// A reading plus the alias set a joining consumer needs to find it under the
// publisher's own spelling.
export type BenchmarkOverlayReading = AiModelBenchmark & {
aliases: string[];
};
// The overlay is the cross-repo artifact: it carries what the catalog embeds
// plus the aliases. Per-effort readings nest under the model's entry so a
// consumer that reads only the top-level fields keeps working.
export type BenchmarkOverlayEntry = BenchmarkOverlayReading & {
benchmarkByEffort?: Partial<Record<AiModelEffort, BenchmarkOverlayReading>>;
};

View File

@@ -0,0 +1,13 @@
import { type AiModelEffort } from 'twenty-shared/ai';
export type BenchmarkRecord = {
intelligenceIndex?: number;
outputTokensPerSecond?: number;
costPerTask?: number;
// The effort the publisher's row was measured at, when its name says so.
effort?: AiModelEffort;
// Set only on a record recovered from the committed overlay, so preserved
// measurements keep the date they were actually taken.
measuredAt?: string;
aliases: string[];
};

View File

@@ -0,0 +1,46 @@
// Deliberately not extensible: a field a route cannot restate is a field it
// cannot drift on, which is the property this pipeline is for.
export type CatalogSpecModelOverrides = {
label?: string;
isDeprecated?: boolean;
contextWindowTokens?: number;
maxOutputTokens?: number;
inputCostPerMillionTokens?: number;
outputCostPerMillionTokens?: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
};
export type CatalogSpecModel = CatalogSpecModelOverrides & {
model: string;
// `eu.anthropic.claude-opus-4-7` for a Bedrock deployment of
// `claude-opus-4-7`.
as?: string;
};
// Every model the vendor publishes, so a route that serves a whole vendor
// picks up new models with the daily sync instead of waiting for an edit here.
export type CatalogSpecModelSelector = {
vendor: string;
};
export type CatalogSpecProvider = {
name: string;
npm: string;
label?: string;
apiKey?: string;
baseUrl?: string;
region?: string;
authType?: string;
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
dataResidency?: string;
zeroDataRetention?: boolean;
labelSuffix?: string;
models: (string | CatalogSpecModel)[] | CatalogSpecModelSelector;
};
export type CatalogSpec = {
providers: CatalogSpecProvider[];
};

View File

@@ -0,0 +1,10 @@
export type CoverageReport = {
generalPurposeModelCount: number;
scoredGeneralPurposeModelCount: number;
costedGeneralPurposeModelCount: number;
unscoredGeneralPurposeModelIds: string[];
specializedModelCount: number;
declaredEffortVariantCount: number;
scoredEffortVariantCount: number;
unscoredEffortVariantIds: string[];
};

View File

@@ -0,0 +1,11 @@
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { type BenchmarkIndex } from './benchmark-index.type';
import { type GeneratedCatalog } from './generated-catalog.type';
export type EnrichCatalogArgs = {
catalog: GeneratedCatalog;
modelsDevData: ModelsDevData;
benchmarkIndex: BenchmarkIndex;
measuredAt: string;
};

View File

@@ -0,0 +1,3 @@
import { type GeneratedProvider } from './generated-provider.type';
export type GeneratedCatalog = Record<string, GeneratedProvider>;

View File

@@ -0,0 +1,27 @@
import { type AiModelEffort, type DataResidency } from 'twenty-shared/ai';
import { type AiModelBenchmark } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-benchmark.type';
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
export type GeneratedModel = {
name: string;
label: string;
description?: string;
modelFamily?: ModelFamily;
inputCostPerMillionTokens?: number;
outputCostPerMillionTokens?: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
longContextCost?: LongContextCost;
contextWindowTokens?: number;
maxOutputTokens?: number;
modalities?: string[];
supportsReasoning?: boolean;
efforts?: AiModelEffort[];
dataResidency?: DataResidency;
zeroDataRetention?: boolean;
benchmark?: AiModelBenchmark;
benchmarkByEffort?: Partial<Record<AiModelEffort, AiModelBenchmark>>;
isDeprecated?: boolean;
};

View File

@@ -0,0 +1,5 @@
import { type GeneratedModel } from './generated-model.type';
export type GeneratedProvider = {
models: GeneratedModel[];
};

View File

@@ -0,0 +1,13 @@
import { type AiModelEffort } from 'twenty-shared/ai';
import { type ModelsDevModel } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-model.type';
import { type BenchmarkIndex } from './benchmark-index.type';
export type MatchBenchmarksArgs = {
modelName: string;
siblingModels: Record<string, ModelsDevModel>;
benchmarkIndex: BenchmarkIndex;
measuredAt: string;
efforts?: AiModelEffort[];
};

View File

@@ -0,0 +1,21 @@
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
// A 200 carrying an empty or reshaped payload would otherwise generate an
// empty catalog, and the sync PR automerges over the real one.
export const assertPayloadIsUsable = ({
data,
vendors,
}: {
data: ModelsDevData;
vendors: string[];
}): void => {
const missing = vendors.filter(
(vendor) => Object.keys(data[vendor]?.models ?? {}).length === 0,
);
if (missing.length > 0) {
throw new Error(
`models.dev payload is missing models for ${missing.join(', ')}; refusing to overwrite the catalog`,
);
}
};

View File

@@ -0,0 +1,164 @@
import { isDefined } from 'twenty-shared/utils';
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
import { type ModelsDevModel } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-model.type';
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
import { type GeneratedCatalog } from '../types/generated-catalog.type';
import { type GeneratedModel } from '../types/generated-model.type';
const EXCLUDED_MODEL_PREFIXES = [
'text-embedding',
'embedding',
'dall-e',
'tts-',
'whisper',
'moderation',
'davinci',
'babbage',
'ada',
'curie',
'text-search',
'text-similarity',
'code-search',
'text-davinci',
'text-curie',
'text-babbage',
'text-ada',
'ft:',
'canary',
];
const EXCLUDED_MODEL_SUFFIXES = ['-audio-preview', '-realtime-preview'];
const LONG_CONTEXT_THRESHOLD_TOKENS = 200000;
const isLanguageModel = (modelId: string): boolean => {
const lowerId = modelId.toLowerCase();
if (EXCLUDED_MODEL_PREFIXES.some((prefix) => lowerId.startsWith(prefix))) {
return false;
}
return !EXCLUDED_MODEL_SUFFIXES.some((suffix) => lowerId.endsWith(suffix));
};
const meetsInclusionCriteria = (modelData: ModelsDevModel): boolean =>
modelData.status !== 'beta' &&
(modelData.tool_call ?? false) &&
isDefined(modelData.cost?.input) &&
isDefined(modelData.limit?.context);
const extractCost = ({
modelData,
model,
}: {
modelData: ModelsDevModel;
model: GeneratedModel;
}): void => {
const cost = modelData.cost;
if (!isDefined(cost)) {
return;
}
model.inputCostPerMillionTokens = cost.input;
model.outputCostPerMillionTokens = cost.output;
model.cachedInputCostPerMillionTokens = cost.cache_read;
model.cacheCreationCostPerMillionTokens = cost.cache_write;
const longContextCost = cost.context_over_200k;
if (!isDefined(longContextCost?.input)) {
return;
}
model.longContextCost = {
inputCostPerMillionTokens: longContextCost.input,
outputCostPerMillionTokens:
longContextCost.output ?? model.outputCostPerMillionTokens ?? 0,
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
cachedInputCostPerMillionTokens: longContextCost.cache_read,
cacheCreationCostPerMillionTokens: longContextCost.cache_write,
};
};
const buildModel = ({
providerName,
modelId,
modelData,
}: {
providerName: string;
modelId: string;
modelData: ModelsDevModel;
}): GeneratedModel => {
const model: GeneratedModel = {
name: modelId,
label: modelData.name ?? modelId,
modelFamily: inferModelFamily(providerName, modelId),
};
extractCost({ modelData, model });
model.contextWindowTokens = modelData.limit?.context;
model.maxOutputTokens = modelData.limit?.output;
const modalities = (modelData.modalities?.input ?? []).filter(
(modality) => modality !== 'text',
);
if (modalities.length > 0) {
model.modalities = modalities;
}
if (modelData.reasoning) {
model.supportsReasoning = true;
}
if (modelData.status === 'deprecated') {
model.isDeprecated = true;
}
return model;
};
// Which vendors we carry is a deployment decision, so it is read from the
// spec that states it rather than inferred from how the SDK names a package.
export const buildCatalog = ({
data,
vendors,
}: {
data: ModelsDevData;
vendors: string[];
}): GeneratedCatalog => {
const catalog: GeneratedCatalog = {};
for (const providerName of vendors) {
const providerData = data[providerName];
if (!isDefined(providerData)) {
// oxlint-disable-next-line no-console
console.warn(`Provider "${providerName}" not found in models.dev`);
continue;
}
const models = Object.entries(providerData.models)
.filter(
([modelId, modelData]) =>
isLanguageModel(modelId) && meetsInclusionCriteria(modelData),
)
.map(([modelId, modelData]) =>
buildModel({ providerName, modelId, modelData }),
);
if (models.length === 0) {
// oxlint-disable-next-line no-console
console.warn(`No qualifying models for "${providerName}", skipping`);
continue;
}
catalog[providerName] = { models };
}
return catalog;
};

View File

@@ -0,0 +1,67 @@
import { isDefined } from 'twenty-shared/utils';
import { type CoverageReport } from '../types/coverage-report.type';
import { type GeneratedCatalog } from '../types/generated-catalog.type';
// Models whose job is not general reasoning (speech, image, embeddings,
// computer use, deep research). A capability index for them would be
// meaningless, so they are excluded from the coverage denominator rather than
// counted as gaps.
const SPECIALIZED_MODEL_PATTERN =
/realtime|voxtral|tts|audio|-live|computer-use|deep-research|-image|pixtral|codestral|embed/;
export const buildCoverageReport = (
catalog: GeneratedCatalog,
): CoverageReport => {
const report: CoverageReport = {
generalPurposeModelCount: 0,
scoredGeneralPurposeModelCount: 0,
costedGeneralPurposeModelCount: 0,
unscoredGeneralPurposeModelIds: [],
specializedModelCount: 0,
declaredEffortVariantCount: 0,
scoredEffortVariantCount: 0,
unscoredEffortVariantIds: [],
};
for (const [providerName, provider] of Object.entries(catalog)) {
for (const model of provider.models) {
if (model.isDeprecated) {
continue;
}
if (SPECIALIZED_MODEL_PATTERN.test(model.name)) {
report.specializedModelCount += 1;
continue;
}
report.generalPurposeModelCount += 1;
if (isDefined(model.benchmark?.costPerTask)) {
report.costedGeneralPurposeModelCount += 1;
}
if (isDefined(model.benchmark?.intelligenceIndex)) {
report.scoredGeneralPurposeModelCount += 1;
} else {
report.unscoredGeneralPurposeModelIds.push(
`${providerName}/${model.name}`,
);
}
for (const effort of model.efforts ?? []) {
report.declaredEffortVariantCount += 1;
if (isDefined(model.benchmarkByEffort?.[effort]?.intelligenceIndex)) {
report.scoredEffortVariantCount += 1;
} else {
report.unscoredEffortVariantIds.push(
`${providerName}/${model.name}@${effort}`,
);
}
}
}
}
return report;
};

View File

@@ -0,0 +1,10 @@
import { type AiModelEffort } from 'twenty-shared/ai';
// A per-effort row is filed beside the bare model key, so the ceiling lookup the
// catalog has always done keeps working while a variant can ask for the figure
// taken at its own effort. `@` survives name normalization, so the two key
// spaces cannot collide.
export const buildEffortLookupKey = (
normalizedModelName: string,
effort: AiModelEffort,
): string => `${normalizedModelName}@${effort}`;

View File

@@ -0,0 +1,69 @@
import { isDefined } from 'twenty-shared/utils';
import { type ModelsDevModel } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-model.type';
const DATE_SUFFIX = /-\d{4}-?\d{2}-?\d{2}$/;
const ROLLING_SUFFIX = /-(latest|preview|exp)$/;
const fingerprint = (model: ModelsDevModel): string =>
JSON.stringify([model.cost ?? {}, model.limit ?? {}]);
// `mistral-large-latest` is never benchmarked under that name, so it is
// resolved to the dated release it currently points at by matching price and
// limits against its non-rolling siblings. Re-resolves on its own when the
// provider repoints the alias.
const resolveRollingAlias = (
modelName: string,
siblingModels: Record<string, ModelsDevModel>,
): string | undefined => {
const model = siblingModels[modelName];
if (!isDefined(model) || !ROLLING_SUFFIX.test(modelName)) {
return undefined;
}
const target = fingerprint(model);
const twins = Object.entries(siblingModels)
.filter(
([siblingName, sibling]) =>
siblingName !== modelName &&
!ROLLING_SUFFIX.test(siblingName) &&
fingerprint(sibling) === target,
)
// Falling back to the name keeps this deterministic when a provider dates
// neither twin, and for the date-stamped ids providers actually use it
// still lands on the newer one.
.sort(
([nameA, a], [nameB, b]) =>
(b.release_date ?? '').localeCompare(a.release_date ?? '') ||
nameB.localeCompare(nameA),
);
return twins[0]?.[0];
};
export const buildLookupCandidates = ({
modelName,
siblingModels,
}: {
modelName: string;
siblingModels: Record<string, ModelsDevModel>;
}): string[] => {
const candidates = [modelName, modelName.replace(DATE_SUFFIX, '')];
const resolved = resolveRollingAlias(modelName, siblingModels);
if (isDefined(resolved)) {
// Once the release an alias points at is known, only a row naming that
// release will do. Every undated spelling is some other release the
// publisher happens to have measured: `mistral-large` on the leaderboard is
// the Feb '24 model, and standing in for `mistral-large-2512` it published a
// two-year-old score as current.
return [...new Set([...candidates, resolved])];
}
// No dated release to point at, so the bare name is the only thing left and
// whichever release the publisher measured is the best answer available.
return [...new Set([...candidates, modelName.replace(ROLLING_SUFFIX, '')])];
};

View File

@@ -0,0 +1,43 @@
import { isDefined } from 'twenty-shared/utils';
import { type GeneratedCatalog } from '../types/generated-catalog.type';
import { type GeneratedModel } from '../types/generated-model.type';
// models.dev knows neither which effort levels a model takes nor its routing
// facts, so a rebuild would drop them without this.
const CARRIED_OVER_FIELDS = [
'efforts',
'dataResidency',
'zeroDataRetention',
] as const satisfies readonly (keyof GeneratedModel)[];
export const carryOverCommittedFields = ({
catalog,
committedCatalog,
}: {
catalog: GeneratedCatalog;
committedCatalog: GeneratedCatalog;
}): void => {
for (const [providerName, provider] of Object.entries(catalog)) {
const committedModels = new Map(
(committedCatalog[providerName]?.models ?? []).map((model) => [
model.name,
model,
]),
);
for (const model of provider.models) {
const committedModel = committedModels.get(model.name);
if (!isDefined(committedModel)) {
continue;
}
for (const field of CARRIED_OVER_FIELDS) {
if (isDefined(committedModel[field])) {
Object.assign(model, { [field]: committedModel[field] });
}
}
}
}
};

View File

@@ -0,0 +1,71 @@
import { type AiModelEffort } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { type AiModelBenchmark } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-benchmark.type';
import {
type BenchmarkOverlayEntry,
type BenchmarkOverlayReading,
} from '../types/benchmark-overlay-entry.type';
import { type EnrichCatalogArgs } from '../types/enrich-catalog-args.type';
import { matchBenchmarks } from './match-benchmarks.util';
// The catalog embeds the figures only; the aliases stay in the overlay, which
// is where a joining consumer looks for them.
const toCatalogReadings = (
readings: Partial<Record<AiModelEffort, BenchmarkOverlayReading>>,
): Partial<Record<AiModelEffort, AiModelBenchmark>> =>
Object.fromEntries(
Object.entries(readings).map(
([effort, { aliases: _aliases, ...reading }]) => [effort, reading],
),
);
export const enrichCatalog = ({
catalog,
modelsDevData,
benchmarkIndex,
measuredAt,
}: EnrichCatalogArgs): Record<string, BenchmarkOverlayEntry> => {
const overlay: Record<string, BenchmarkOverlayEntry> = {};
for (const [providerName, provider] of Object.entries(catalog)) {
const siblingModels = modelsDevData[providerName]?.models ?? {};
provider.models = provider.models.map((model) => {
const match = matchBenchmarks({
modelName: model.name,
siblingModels,
benchmarkIndex,
measuredAt,
efforts: model.efforts,
});
if (!isDefined(match)) {
return model;
}
const { benchmark, aliases, benchmarkByEffort } = match;
overlay[model.name] = {
...benchmark,
aliases,
...(isDefined(benchmarkByEffort) ? { benchmarkByEffort } : {}),
};
const { isDeprecated, ...rest } = model;
return {
...rest,
benchmark,
...(isDefined(benchmarkByEffort)
? { benchmarkByEffort: toCatalogReadings(benchmarkByEffort) }
: {}),
...(isDefined(isDeprecated) ? { isDeprecated } : {}),
};
});
}
return overlay;
};

View File

@@ -0,0 +1,219 @@
import { isDefined } from 'twenty-shared/utils';
import { buildEffortLookupKey } from './build-effort-lookup-key.util';
import { normalizeModelName } from './normalize-model-name.util';
import { parseArtificialAnalysisEffort } from './parse-artificial-analysis-effort.util';
import { type BenchmarkIndex } from '../types/benchmark-index.type';
import { type BenchmarkRecord } from '../types/benchmark-record.type';
const DEFAULT_ARTIFICIAL_ANALYSIS_API_URL =
'https://artificialanalysis.ai/api/v2/language/models/free';
const PAGE_SIZE = 200;
type ArtificialAnalysisModel = Record<string, unknown> & {
id?: string;
slug?: string;
name?: string;
};
type ArtificialAnalysisPage = {
data?: unknown;
pagination?: { has_more?: unknown };
};
// This endpoint returns 0 for "not measured", and a zero tokens-per-second is a
// lie rather than a datum.
const readPositiveNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: undefined;
const readNested = ({
model,
path,
}: {
model: ArtificialAnalysisModel;
path: string[];
}): number | undefined => {
let current: unknown = model;
for (const key of path) {
if (typeof current !== 'object' || current === null) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return readPositiveNumber(current);
};
const readModels = (page: ArtificialAnalysisPage): ArtificialAnalysisModel[] =>
Array.isArray(page.data) ? (page.data as ArtificialAnalysisModel[]) : [];
// How much a row actually says. The endpoint carries several rows per model and
// some hosts publish no measurements at all, so an unmeasured row must never
// displace a measured one under the same key.
const informationScore = (record: BenchmarkRecord): number =>
[
record.intelligenceIndex,
record.outputTokensPerSecond,
record.costPerTask,
].filter(isDefined).length;
// The rows for one model are the same model at different reasoning efforts, and
// the publisher spells the effort only inside a display name. Taking the
// best-scoring row gives every model its ceiling, measured the same way, which
// is the whole reason for standing on one publisher. Picking by how populated a
// row happened to be scored Sonnet 4.6 at low effort against Sonnet 5 at max,
// and the gap read as a capability difference.
const preferOver = (
candidate: BenchmarkRecord,
existing: BenchmarkRecord,
): boolean => {
const candidateIndex = candidate.intelligenceIndex ?? -1;
const existingIndex = existing.intelligenceIndex ?? -1;
if (candidateIndex !== existingIndex) {
return candidateIndex > existingIndex;
}
return informationScore(candidate) > informationScore(existing);
};
const fetchPage = async ({
url,
apiKey,
page,
}: {
url: string;
apiKey: string;
page: number;
}): Promise<ArtificialAnalysisPage> => {
const pageUrl = new URL(url);
pageUrl.searchParams.set('page', String(page));
pageUrl.searchParams.set('page_size', String(PAGE_SIZE));
const response = await fetch(pageUrl, {
headers: { 'x-api-key': apiKey },
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return (await response.json()) as ArtificialAnalysisPage;
};
// The list is paginated and the free tier allows ten requests a day, so every
// page is read in one run rather than spread across runs.
const fetchAllModels = async ({
url,
apiKey,
}: {
url: string;
apiKey: string;
}): Promise<ArtificialAnalysisModel[]> => {
const models: ArtificialAnalysisModel[] = [];
for (let page = 1; ; page += 1) {
const response = await fetchPage({ url, apiKey, page });
models.push(...readModels(response));
if (response.pagination?.has_more !== true) {
return models;
}
}
};
export const fetchArtificialAnalysisBenchmarks = async (
apiKey: string,
): Promise<BenchmarkIndex> => {
const url =
process.env.ARTIFICIAL_ANALYSIS_API_URL ??
DEFAULT_ARTIFICIAL_ANALYSIS_API_URL;
const models = await fetchAllModels({ url, apiKey });
if (models.length === 0) {
throw new Error(`No models in response from ${url}`);
}
const index: BenchmarkIndex = new Map();
const file = (key: string, record: BenchmarkRecord): void => {
const existing = index.get(key);
if (!isDefined(existing) || preferOver(record, existing)) {
index.set(key, record);
}
};
for (const model of models) {
// One malformed row must not cost us every measurement in the response.
if (typeof model !== 'object' || model === null) {
continue;
}
const aliases = [model.slug, model.id, model.name].filter(isDefined);
// Cost per task is the price of one task of the intelligence index run at
// this row's effort, so it reflects reasoning volume where per-token
// pricing cannot.
const record: BenchmarkRecord = {
intelligenceIndex: readNested({
model,
path: ['evaluations', 'artificial_analysis_intelligence_index'],
}),
outputTokensPerSecond: readNested({
model,
path: ['performance', 'median_output_tokens_per_second'],
}),
costPerTask: readNested({
model,
path: [
'artificial_analysis_intelligence_index_cost',
'cost_per_task',
'total_cost',
],
}),
effort:
typeof model.name === 'string'
? parseArtificialAnalysisEffort(model.name)
: undefined,
aliases,
};
if (informationScore(record) === 0) {
continue;
}
for (const alias of aliases) {
const key = normalizeModelName(alias);
if (key.length === 0) {
continue;
}
// The bare key keeps the ceiling; the effort key lets a pinned variant
// read the figure taken at its own effort.
file(key, record);
if (isDefined(record.effort)) {
file(buildEffortLookupKey(key, record.effort), record);
}
}
}
if (index.size === 0) {
throw new Error(
`${models.length} models returned from ${url} but none carried a measurement`,
);
}
return index;
};

View File

@@ -0,0 +1,94 @@
import { type AiModelEffort } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { type AiModelBenchmark } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-benchmark.type';
import { type BenchmarkIndex } from '../types/benchmark-index.type';
import { type BenchmarkMatch } from '../types/benchmark-match.type';
import { type BenchmarkOverlayReading } from '../types/benchmark-overlay-entry.type';
import { type BenchmarkRecord } from '../types/benchmark-record.type';
import { type MatchBenchmarksArgs } from '../types/match-benchmarks-args.type';
import { buildEffortLookupKey } from './build-effort-lookup-key.util';
import { buildLookupCandidates } from './build-lookup-candidates.util';
import { normalizeModelName } from './normalize-model-name.util';
const lookup = (
index: BenchmarkIndex,
keys: string[],
): BenchmarkRecord | undefined => {
for (const key of keys) {
const record = index.get(key);
if (isDefined(record)) {
return record;
}
}
return undefined;
};
// The publisher lists plenty of models it has measured nothing about, and a row
// of pure aliases says nothing worth carrying into either artifact.
const hasMeasurement = (record: BenchmarkRecord): boolean =>
[
record.intelligenceIndex,
record.outputTokensPerSecond,
record.costPerTask,
].some(isDefined);
const toBenchmark = (
record: BenchmarkRecord,
measuredAt: string,
): AiModelBenchmark => ({
intelligenceIndex: record.intelligenceIndex,
outputTokensPerSecond: record.outputTokensPerSecond,
costPerTask: record.costPerTask,
effort: record.effort,
measuredAt: record.measuredAt ?? measuredAt,
});
export const matchBenchmarks = ({
modelName,
siblingModels,
benchmarkIndex,
measuredAt,
efforts,
}: MatchBenchmarksArgs): BenchmarkMatch | undefined => {
const keys = buildLookupCandidates({ modelName, siblingModels }).map(
normalizeModelName,
);
const record = lookup(benchmarkIndex, keys);
if (!isDefined(record) || !hasMeasurement(record)) {
return undefined;
}
const benchmarkByEffort: Partial<
Record<AiModelEffort, BenchmarkOverlayReading>
> = {};
// A declared effort without a row of its own stays blank: the ceiling was
// measured at another effort and would overstate it.
for (const effort of efforts ?? []) {
const effortRecord = lookup(
benchmarkIndex,
keys.map((key) => buildEffortLookupKey(key, effort)),
);
if (isDefined(effortRecord) && hasMeasurement(effortRecord)) {
benchmarkByEffort[effort] = {
...toBenchmark(effortRecord, measuredAt),
effort,
aliases: [...new Set(effortRecord.aliases)],
};
}
}
return {
benchmark: toBenchmark(record, measuredAt),
aliases: [...new Set([modelName, ...record.aliases])],
...(Object.keys(benchmarkByEffort).length > 0 ? { benchmarkByEffort } : {}),
};
};

View File

@@ -0,0 +1,12 @@
// Benchmark publishers, providers and the AI SDK all spell the same model
// differently (`claude-sonnet-4-6`, `claude-sonnet-4.6`, `Claude Sonnet 4.6`),
// so every lookup key goes through this before it is compared. The trailing
// `-v1:0` strip lets a Bedrock deployment id join the same row as the model it
// serves.
export const normalizeModelName = (modelName: string): string =>
modelName
.toLowerCase()
.replace(/:batch$/, '')
.replace(/\(.*\)/g, '')
.replace(/-v\d+(:\d+)?$/, '')
.replace(/[\s.\-_/]/g, '');

View File

@@ -0,0 +1,35 @@
import { type AiModelEffort, isAiModelEffort } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
// A row run with reasoning switched off carries no effort word at all.
const NO_REASONING_MARKERS = new Set(['non-reasoning', 'nonreasoning']);
// The publisher spells the configuration only inside the trailing parentheses
// of a display name: `GPT-5.6 Sol (max)`, `Gemini 3.8 Flash (high)`,
// `Claude Opus 5 (Adaptive Reasoning, Max Effort)`. Dates, fallbacks and a
// bare "Reasoning" in the same place name no effort.
export const parseArtificialAnalysisEffort = (
displayName: string,
): AiModelEffort | undefined => {
const configuration = displayName.match(/\(([^()]*)\)\s*$/)?.[1];
if (!isDefined(configuration)) {
return undefined;
}
const segments = configuration
.split(',')
.map((segment) => segment.trim().toLowerCase());
const effort = segments
.map((segment) => segment.replace(/\s+effort$/, ''))
.find(isAiModelEffort);
if (isDefined(effort)) {
return effort;
}
return segments.some((segment) => NO_REASONING_MARKERS.has(segment))
? 'none'
: undefined;
};

View File

@@ -0,0 +1,242 @@
// twenty-shared by source path rather than by package name: the consuming
// repository runs this from a sparse checkout with no workspace install, so
// 'twenty-shared/ai' would not resolve there.
import { isAiSdkPackage } from '../../../../twenty-shared/src/ai/utils/is-ai-sdk-package.util';
import { isDataResidency } from '../../../../twenty-shared/src/ai/utils/is-data-residency.util';
import {
type CatalogSpec,
type CatalogSpecModel,
type CatalogSpecModelOverrides,
type CatalogSpecProvider,
} from '../types/catalog-spec.type';
// Structural, so the catalog needs no schema here and this file can run from a
// checkout that has not installed the monorepo.
type CanonicalModel = { name: string; label?: string } & Record<
string,
unknown
>;
type CanonicalProvider = { models?: CanonicalModel[] } & Record<
string,
unknown
>;
export type CanonicalCatalog = Record<string, CanonicalProvider>;
const OVERRIDABLE_FIELDS = [
'label',
'isDeprecated',
'contextWindowTokens',
'maxOutputTokens',
'inputCostPerMillionTokens',
'outputCostPerMillionTokens',
'cachedInputCostPerMillionTokens',
'cacheCreationCostPerMillionTokens',
] as const satisfies readonly (keyof CatalogSpecModelOverrides)[];
const NUMERIC_FIELDS = [
'contextWindowTokens',
'maxOutputTokens',
'inputCostPerMillionTokens',
'outputCostPerMillionTokens',
'cachedInputCostPerMillionTokens',
'cacheCreationCostPerMillionTokens',
] as const satisfies readonly (keyof CatalogSpecModelOverrides)[];
const PROVIDER_CREDENTIAL_FIELDS = [
'label',
'apiKey',
'baseUrl',
'region',
'authType',
'accessKeyId',
'secretAccessKey',
'sessionToken',
] as const;
const asSpecModel = (entry: string | CatalogSpecModel): CatalogSpecModel =>
typeof entry === 'string' ? { model: entry } : entry;
const indexCanonicalModels = (
catalog: CanonicalCatalog,
): Map<string, CanonicalModel> => {
const models = new Map<string, CanonicalModel>();
for (const provider of Object.values(catalog)) {
for (const model of provider.models ?? []) {
models.set(model.name, model);
}
}
return models;
};
const projectModel = ({
canonicalModel,
specModel,
provider,
}: {
canonicalModel: CanonicalModel;
specModel: CatalogSpecModel;
provider: CatalogSpecProvider;
}): CanonicalModel => {
const overrides = Object.fromEntries(
OVERRIDABLE_FIELDS.filter((field) => specModel[field] !== undefined).map(
(field) => [field, specModel[field]],
),
);
const routeFields = {
...(provider.dataResidency === undefined
? {}
: { dataResidency: provider.dataResidency }),
...(provider.zeroDataRetention === undefined
? {}
: { zeroDataRetention: provider.zeroDataRetention }),
};
return {
...canonicalModel,
...routeFields,
...overrides,
name: specModel.as ?? canonicalModel.name,
// The suffix marks the route, so it is appended to whichever name the model
// ends up with rather than to the catalog's.
label: `${specModel.label ?? canonicalModel.label ?? canonicalModel.name}${provider.labelSuffix ?? ''}`,
};
};
const assertProviderIsUsable = ({
provider,
seenNames,
}: {
provider: CatalogSpecProvider;
seenNames: Set<string>;
}): void => {
// Providers key an object, so a repeat would drop the first one's credentials
// and models on the floor, and `__proto__` would drop its own.
if (seenNames.has(provider.name) || provider.name === '__proto__') {
throw new Error(`Provider "${provider.name}" is repeated or reserved`);
}
if (!isAiSdkPackage(provider.npm)) {
throw new Error(
`Provider "${provider.name}" names an unsupported SDK package: ${provider.npm}`,
);
}
if (
provider.dataResidency !== undefined &&
!isDataResidency(provider.dataResidency)
) {
throw new Error(
`Provider "${provider.name}" names an unsupported data residency: ${provider.dataResidency}`,
);
}
};
const assertNumbersAreUsable = ({
provider,
specModel,
}: {
provider: CatalogSpecProvider;
specModel: CatalogSpecModel;
}): void => {
for (const field of NUMERIC_FIELDS) {
const value = specModel[field];
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
throw new Error(
`${provider.name}/${specModel.model} sets ${field} to ${value}, which is not a usable number`,
);
}
}
};
// A spec naming a model the catalog does not carry is the failure this whole
// pipeline exists to prevent: it publishes a route to nothing, and the tier
// chains then fall through to a neighbouring rung in silence.
const resolveModels = ({
provider,
canonicalCatalog,
canonicalModels,
}: {
provider: CatalogSpecProvider;
canonicalCatalog: CanonicalCatalog;
canonicalModels: Map<string, CanonicalModel>;
}): { canonicalModel: CanonicalModel; specModel: CatalogSpecModel }[] => {
if (!Array.isArray(provider.models)) {
const { vendor } = provider.models;
const vendorModels = canonicalCatalog[vendor]?.models ?? [];
if (vendorModels.length === 0) {
throw new Error(
`Provider "${provider.name}" serves every model from "${vendor}", which the catalog does not carry`,
);
}
return vendorModels.map((canonicalModel) => ({
canonicalModel,
specModel: { model: canonicalModel.name },
}));
}
const specModels = provider.models.map(asSpecModel);
const unknown = specModels
.filter(({ model }) => !canonicalModels.has(model))
.map(({ model }) => `${provider.name}/${model}`);
if (unknown.length > 0) {
throw new Error(
`The catalog does not carry: ${unknown.join(', ')}. Check the model names, or wait for the catalog sync to pick them up.`,
);
}
return specModels.map((specModel) => {
assertNumbersAreUsable({ provider, specModel });
const canonicalModel = canonicalModels.get(specModel.model);
if (canonicalModel === undefined) {
throw new Error(`The catalog does not carry ${specModel.model}`);
}
return { canonicalModel, specModel };
});
};
export const projectCatalog = ({
canonicalCatalog,
spec,
}: {
canonicalCatalog: CanonicalCatalog;
spec: CatalogSpec;
}): CanonicalCatalog => {
const canonicalModels = indexCanonicalModels(canonicalCatalog);
const projected: CanonicalCatalog = {};
const seenNames = new Set<string>();
for (const provider of spec.providers) {
assertProviderIsUsable({ provider, seenNames });
seenNames.add(provider.name);
const credentials = Object.fromEntries(
PROVIDER_CREDENTIAL_FIELDS.filter(
(field) => provider[field] !== undefined,
).map((field) => [field, provider[field]]),
);
projected[provider.name] = {
npm: provider.npm,
label: provider.label ?? provider.name,
...credentials,
models: resolveModels({
provider,
canonicalCatalog,
canonicalModels,
}).map((resolved) => projectModel({ ...resolved, provider })),
};
}
return projected;
};

View File

@@ -0,0 +1,103 @@
import * as fs from 'fs';
import { type AiModelEffort, isAiModelEffort } from 'twenty-shared/ai';
import { buildEffortLookupKey } from './build-effort-lookup-key.util';
import { normalizeModelName } from './normalize-model-name.util';
import { type BenchmarkIndex } from '../types/benchmark-index.type';
import { type BenchmarkRecord } from '../types/benchmark-record.type';
type CommittedReading = Partial<BenchmarkRecord> & {
measuredAt?: string;
aliases?: string[];
};
type CommittedEntry = CommittedReading & {
benchmarkByEffort?: Partial<Record<AiModelEffort, CommittedReading>>;
};
const toRecord = ({
reading,
modelName,
effort,
}: {
reading: CommittedReading;
modelName: string;
effort?: AiModelEffort;
}): BenchmarkRecord => ({
intelligenceIndex: reading.intelligenceIndex,
outputTokensPerSecond: reading.outputTokensPerSecond,
costPerTask: reading.costPerTask,
// The key a reading is filed under is the authority on its effort.
effort: effort ?? reading.effort,
measuredAt: reading.measuredAt,
aliases: reading.aliases ?? [modelName],
});
const file = ({
index,
record,
modelName,
toKey,
}: {
index: BenchmarkIndex;
record: BenchmarkRecord;
modelName: string;
toKey: (normalizedName: string) => string;
}): void => {
for (const alias of record.aliases) {
const key = normalizeModelName(alias);
if (key.length > 0 && !index.has(toKey(key))) {
index.set(toKey(key), record);
}
}
// An entry always claims its own name, whatever a sibling's alias list says.
const ownKey = normalizeModelName(modelName);
if (ownKey.length > 0) {
index.set(toKey(ownKey), record);
}
};
// The last published measurements, read back so a failed fetch degrades to
// stale data rather than deleting the catalog's benchmarks. Each record keeps
// its own measuredAt, so a stale entry stays visibly stale.
export const readCommittedBenchmarks = (filePath: string): BenchmarkIndex => {
if (!fs.existsSync(filePath)) {
return new Map();
}
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
models?: Record<string, CommittedEntry>;
};
const index: BenchmarkIndex = new Map();
for (const [modelName, entry] of Object.entries(parsed.models ?? {})) {
file({
index,
record: toRecord({ reading: entry, modelName }),
modelName,
toKey: (normalizedName) => normalizedName,
});
for (const [effort, reading] of Object.entries(
entry.benchmarkByEffort ?? {},
)) {
if (!isAiModelEffort(effort)) {
continue;
}
file({
index,
record: toRecord({ reading, modelName, effort }),
modelName,
toKey: (normalizedName) => buildEffortLookupKey(normalizedName, effort),
});
}
}
return index;
};

View File

@@ -0,0 +1,55 @@
import { type CoverageReport } from '../types/coverage-report.type';
const asPercentage = ({
numerator,
denominator,
}: {
numerator: number;
denominator: number;
}): string =>
denominator === 0 ? 'n/a' : `${Math.round((numerator / denominator) * 100)}%`;
const renderGapList = ({
summary,
modelIds,
}: {
summary: string;
modelIds: string[];
}): string[] =>
modelIds.length === 0
? []
: [
'',
`<details><summary>${summary}</summary>`,
'',
...modelIds.map((modelId) => `- ${modelId}`),
'',
'</details>',
];
export const renderCoverageReport = (report: CoverageReport): string => {
const {
generalPurposeModelCount: total,
scoredGeneralPurposeModelCount: scored,
costedGeneralPurposeModelCount: costed,
declaredEffortVariantCount: declaredVariants,
scoredEffortVariantCount: scoredVariants,
} = report;
return [
'### Benchmark coverage',
'',
`- General-purpose models with an intelligence index: **${scored}/${total}** (${asPercentage({ numerator: scored, denominator: total })})`,
`- Declared effort variants with an index measured at their own effort: **${scoredVariants}/${declaredVariants}** (${asPercentage({ numerator: scoredVariants, denominator: declaredVariants })})`,
`- General-purpose models with a cost per task: **${costed}/${total}** (${asPercentage({ numerator: costed, denominator: total })})`,
`- Specialized models excluded from the denominator: ${report.specializedModelCount}`,
...renderGapList({
summary: 'General-purpose models with no matched intelligence index',
modelIds: report.unscoredGeneralPurposeModelIds,
}),
...renderGapList({
summary: 'Effort variants with no reading at their own effort',
modelIds: report.unscoredEffortVariantIds,
}),
].join('\n');
};

View File

@@ -0,0 +1,66 @@
// Script to build seed front component .tsx files into .mjs using the same
// esbuild pipeline as the twenty-sdk CLI (Remote DOM + JSX wrapping).
//
// Usage: npx tsx scripts/build-seed-front-components.ts
import * as esbuild from 'esbuild';
import { existsSync, readdirSync } from 'fs';
import { join, resolve } from 'path';
import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build';
const ROOT_DIR = resolve(__dirname, '../../..');
const ROOT_NODE_MODULES = resolve(ROOT_DIR, 'node_modules');
const SEED_PROJECT_DIR = resolve(
__dirname,
'../src/engine/metadata-modules/front-component/constants/seed-project',
);
const alias: Record<string, string> = {
react: join(ROOT_NODE_MODULES, 'react'),
'react-dom': join(ROOT_NODE_MODULES, 'react-dom'),
};
const COMPONENTS = readdirSync(SEED_PROJECT_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
existsSync(join(SEED_PROJECT_DIR, entry.name, 'index.tsx')),
)
.map((entry) => entry.name)
.sort();
const build = async () => {
for (const component of COMPONENTS) {
const entryPoint = join(SEED_PROJECT_DIR, component, 'index.tsx');
const outdir = join(SEED_PROJECT_DIR, component);
await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
splitting: false,
format: 'esm',
outExtension: { '.js': '.mjs' },
external: [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
'twenty-shared/*',
],
jsx: 'automatic',
sourcemap: false,
metafile: false,
minify: true,
logLevel: 'info',
outdir,
alias,
plugins: [...getFrontComponentBuildPlugins()],
});
console.log(`Built ${component}/index.mjs`);
}
};
build().catch((error) => {
console.error('Build failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,243 @@
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import semver from 'semver';
const CONSTANTS_DIR = resolve(
__dirname,
'../src/engine/core-modules/upgrade/constants',
);
const AUTO_GENERATED_HEADER = `/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | Generated by: npx nx version:bump twenty-server
* |___/
*/
`;
const incrementMinorVersion = (version: string): string => {
const incremented = semver.inc(version, 'minor');
if (!incremented) {
throw new Error(`Failed to increment version: ${version}`);
}
return incremented;
};
const getCurrentVersion = (): string => {
const filePath = `${CONSTANTS_DIR}/twenty-current-version.constant.ts`;
const content = readFileSync(filePath, 'utf-8');
const match = content.match(/'([0-9.]+)'/);
if (!match) {
throw new Error('Could not extract current version');
}
return match[1];
};
const getPreviousVersions = (): string[] => {
const filePath = `${CONSTANTS_DIR}/twenty-previous-versions.constant.ts`;
const content = readFileSync(filePath, 'utf-8');
const matches = content.match(/'[0-9.]+'/g);
if (!matches) {
return [];
}
return matches.map((m) => m.replace(/'/g, ''));
};
const getNextVersions = (): string[] => {
const filePath = `${CONSTANTS_DIR}/twenty-next-versions.constant.ts`;
const content = readFileSync(filePath, 'utf-8');
const matches = content.match(/'[0-9.]+'/g);
if (!matches) {
return [];
}
return matches.map((m) => m.replace(/'/g, ''));
};
const updateCurrentVersion = (newVersion: string): void => {
const filePath = `${CONSTANTS_DIR}/twenty-current-version.constant.ts`;
writeFileSync(
filePath,
`${AUTO_GENERATED_HEADER}export const TWENTY_CURRENT_VERSION = '${newVersion}' as const;\n`,
);
console.log(`Updated TWENTY_CURRENT_VERSION to '${newVersion}'`);
};
const updatePreviousVersions = (versions: string[]): void => {
const filePath = `${CONSTANTS_DIR}/twenty-previous-versions.constant.ts`;
const formattedVersions = versions.map((v) => ` '${v}',`).join('\n');
writeFileSync(
filePath,
`${AUTO_GENERATED_HEADER}export const TWENTY_PREVIOUS_VERSIONS = [\n${formattedVersions}\n] as const;\n`,
);
console.log(
`Updated TWENTY_PREVIOUS_VERSIONS with ${versions.length} versions`,
);
};
const updateNextVersions = (versions: string[]): void => {
const filePath = `${CONSTANTS_DIR}/twenty-next-versions.constant.ts`;
const formattedVersions = versions.map((v) => ` '${v}',`).join('\n');
writeFileSync(
filePath,
`${AUTO_GENERATED_HEADER}export const TWENTY_NEXT_VERSIONS = [\n${formattedVersions}\n] as const;\n`,
);
console.log(`Updated TWENTY_NEXT_VERSIONS with ${versions.length} versions`);
};
const resolveNewVersion = ({
newVersionArg,
currentVersion,
nextVersions,
}: {
newVersionArg: string | undefined;
currentVersion: string;
nextVersions: string[];
}): string => {
if (newVersionArg) {
if (!semver.valid(newVersionArg)) {
throw new Error(`Invalid version format: ${newVersionArg}`);
}
if (!semver.gt(newVersionArg, currentVersion)) {
throw new Error(
`New version '${newVersionArg}' must be greater than current version '${currentVersion}'`,
);
}
if (nextVersions.length > 0) {
const highestNextVersion = nextVersions[nextVersions.length - 1];
if (semver.gt(newVersionArg, highestNextVersion)) {
throw new Error(
`New version '${newVersionArg}' cannot be greater than highest planned next version '${highestNextVersion}'`,
);
}
}
return newVersionArg;
}
if (nextVersions.length === 0) {
const computed = incrementMinorVersion(currentVersion);
console.log(
`\nNo version provided and TWENTY_NEXT_VERSIONS is empty, computed from current: '${computed}'`,
);
return computed;
}
console.log(
`\nNo version provided, using TWENTY_NEXT_VERSIONS[0]: '${nextVersions[0]}'`,
);
return nextVersions[0];
};
const partitionNextVersions = ({
nextVersions,
newVersion,
}: {
nextVersions: string[];
newVersion: string;
}): {
skippedNextVersions: string[];
remainingNextVersions: string[];
} =>
nextVersions.reduce<{
skippedNextVersions: string[];
remainingNextVersions: string[];
}>(
(acc, version) => {
if (semver.lt(version, newVersion)) {
return {
...acc,
skippedNextVersions: [...acc.skippedNextVersions, version],
};
}
if (semver.gt(version, newVersion)) {
return {
...acc,
remainingNextVersions: [...acc.remainingNextVersions, version],
};
}
return acc;
},
{ skippedNextVersions: [], remainingNextVersions: [] },
);
const bumpVersion = (newVersionArg?: string): void => {
const currentVersion = getCurrentVersion();
const previousVersions = getPreviousVersions();
const nextVersions = getNextVersions();
console.log('\nCurrent state:');
console.log(` TWENTY_CURRENT_VERSION: '${currentVersion}'`);
console.log(
` TWENTY_PREVIOUS_VERSIONS: [${previousVersions.map((v) => `'${v}'`).join(', ')}]`,
);
console.log(
` TWENTY_NEXT_VERSIONS: [${nextVersions.map((v) => `'${v}'`).join(', ')}]`,
);
const newVersion = resolveNewVersion({
newVersionArg,
currentVersion,
nextVersions,
});
const { skippedNextVersions, remainingNextVersions } = partitionNextVersions({
nextVersions,
newVersion,
});
const updatedPreviousVersions = [
...previousVersions,
currentVersion,
...skippedNextVersions,
];
const updatedNextVersions =
remainingNextVersions.length > 0
? remainingNextVersions
: [incrementMinorVersion(newVersion)];
console.log('\nApplying changes:');
updatePreviousVersions(updatedPreviousVersions);
updateCurrentVersion(newVersion);
updateNextVersions(updatedNextVersions);
console.log('\nNew state:');
console.log(` TWENTY_CURRENT_VERSION: '${newVersion}'`);
console.log(
` TWENTY_PREVIOUS_VERSIONS: [${updatedPreviousVersions.map((v) => `'${v}'`).join(', ')}]`,
);
console.log(
` TWENTY_NEXT_VERSIONS: [${updatedNextVersions.map((v) => `'${v}'`).join(', ')}]`,
);
console.log('\nVersion bump complete!');
};
const newVersion = process.argv[2];
bumpVersion(newVersion);

View File

@@ -0,0 +1,229 @@
const IDENTITY_PATTERNS = [
/noreply@anthropic\.com/i,
/@anthropic\.com/i,
/cursoragent@cursor\.com/i,
/copilot-swe-agent\[bot\]/i,
];
const SIGNATURE_PATTERNS = [
/Generated with \[Claude Code\]\(/i,
/Co-Authored-By:[^\n]*<[^>]*@anthropic\.com>/i,
/(Generated|Made|Built) with \[Cursor( Agent)?\]\(/i,
/Co-Authored-By:[^\n]*cursoragent/i,
];
type Commit = {
sha: string;
commit: {
message: string;
author: { name: string; email: string };
committer: { name: string; email: string };
};
};
type ProseSource = {
kind: string;
ref: string;
body: string;
};
async function githubGet<TResponse>(
url: string,
token: string,
): Promise<TResponse> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
},
});
if (!response.ok) {
throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
}
return (await response.json()) as TResponse;
}
async function githubGetPaginated<TItem>(
baseUrl: string,
token: string,
): Promise<TItem[]> {
const items: TItem[] = [];
let page = 1;
for (;;) {
const separator = baseUrl.includes('?') ? '&' : '?';
const batch = await githubGet<TItem[]>(
`${baseUrl}${separator}per_page=100&page=${page}`,
token,
);
items.push(...batch);
if (batch.length < 100) {
return items;
}
page += 1;
}
}
function matchPatterns(text: string, patterns: RegExp[]): string[] {
if (!text) {
return [];
}
return patterns.flatMap((pattern) => {
const match = text.match(pattern);
return match ? [match[0]] : [];
});
}
function findCommitMatches(commit: Commit): string[] {
const identityHaystack = [
commit.commit.author.name,
commit.commit.author.email,
commit.commit.committer.name,
commit.commit.committer.email,
commit.commit.message,
].join(' ');
return [
...matchPatterns(identityHaystack, IDENTITY_PATTERNS),
...matchPatterns(commit.commit.message, SIGNATURE_PATTERNS),
];
}
async function fetchPrCommits(
repo: string,
prNumber: string,
token: string,
): Promise<Commit[]> {
return githubGetPaginated<Commit>(
`https://api.github.com/repos/${repo}/pulls/${prNumber}/commits`,
token,
);
}
async function fetchProseSources(
repo: string,
prNumber: string,
token: string,
): Promise<ProseSource[]> {
const base = `https://api.github.com/repos/${repo}`;
const sources: ProseSource[] = [];
const pullRequest = await githubGet<{
body: string | null;
html_url: string;
}>(`${base}/pulls/${prNumber}`, token);
sources.push({
kind: 'PR description',
ref: pullRequest.html_url,
body: pullRequest.body ?? '',
});
const conversationComments = await githubGetPaginated<{
body: string | null;
html_url: string;
}>(`${base}/issues/${prNumber}/comments`, token);
for (const comment of conversationComments) {
sources.push({
kind: 'Conversation comment',
ref: comment.html_url,
body: comment.body ?? '',
});
}
const reviewComments = await githubGetPaginated<{
body: string | null;
html_url: string;
}>(`${base}/pulls/${prNumber}/comments`, token);
for (const comment of reviewComments) {
sources.push({
kind: 'Inline review comment',
ref: comment.html_url,
body: comment.body ?? '',
});
}
const reviews = await githubGetPaginated<{
body: string | null;
html_url: string;
}>(`${base}/pulls/${prNumber}/reviews`, token);
for (const review of reviews) {
sources.push({
kind: 'Review summary',
ref: review.html_url,
body: review.body ?? '',
});
}
return sources;
}
async function main(): Promise<void> {
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const prNumber = process.env.PR_NUMBER;
if (!token || !repo || !prNumber) {
console.error(
'Error: GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER are required',
);
process.exit(1);
}
const [commits, proseSources] = await Promise.all([
fetchPrCommits(repo, prNumber, token),
fetchProseSources(repo, prNumber, token),
]);
let violations = 0;
for (const commit of commits) {
const matches = findCommitMatches(commit);
if (matches.length > 0) {
console.error(
`::error::Commit ${commit.sha} contains a blocked bot attribution or signature (matched: ${matches.join(', ')})`,
);
violations += 1;
}
}
for (const source of proseSources) {
const matches = matchPatterns(source.body, SIGNATURE_PATTERNS);
if (matches.length > 0) {
console.error(
`::error::${source.kind} contains a bot signature (matched: ${matches.join(', ')}) — ${source.ref}`,
);
violations += 1;
}
}
if (violations > 0) {
console.error(
`\nFound ${violations} item(s) attributed to blocked bot contributors.`,
);
console.error(
'For commits: rewrite the author/committer and strip Co-Authored-By trailers, then force-push:',
);
console.error(
" git rebase -i --exec 'git commit --amend --reset-author --no-edit' origin/main",
);
console.error(
'For the PR description, comments or reviews: remove the auto-generated attribution footer.',
);
process.exit(1);
}
console.log('No blocked contributors found in PR commits, description, comments or reviews.');
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,138 @@
#!/bin/sh
set -eu
LOG_FILE="${TWENTY_COMMAND_LOG_FILE:-/tmp/twenty-command.log}"
PID_FILE="${TWENTY_COMMAND_PID_FILE:-/tmp/twenty-command.pid}"
LOCK_FILE="$PID_FILE.lock"
TAIL_LINES=20
export LOG_FILE PID_FILE
command_pid() { [ -s "$PID_FILE" ] && cat "$PID_FILE" || return 1; }
stream() { exec tail -f "$LOG_FILE"; }
is_our_command() {
[ -r "/proc/$1/cmdline" ] || return 0
tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null | grep -q 'dist/command/command'
}
is_running() {
pid=$(command_pid) && kill -0 "$pid" 2>/dev/null && is_our_command "$pid"
}
detach_hint() {
echo "Ctrl+C detaches the stream only. Use 'yarn command:prod:background:stop' to stop the run."
}
show_tail() {
[ -f "$LOG_FILE" ] || return 0
echo "Tail of $LOG_FILE:" >&2
tail -n "$TAIL_LINES" "$LOG_FILE"
}
last_run() {
code=$(grep '^EXIT=' "$LOG_FILE" 2>/dev/null | tail -n 1 | cut -d= -f2)
case "$code" in
0) echo "completed" ;;
130) echo "stopped by SIGINT (graceful only if the command handles it)" ;;
143) echo "stopped by SIGTERM (graceful only if the command handles it)" ;;
137) echo "killed (SIGKILL), partial work possible" ;;
"") echo "left no exit code — killed, or never started" ;;
*) echo "failed (exit $code)" ;;
esac
}
start() {
[ $# -ge 1 ] || {
echo "usage: yarn command:prod:background <command> [args]" >&2; exit 1
}
command -v setsid > /dev/null || {
echo "Failed to start: setsid not found, cannot detach the run from this terminal" >&2
echo "Use 'yarn command:prod $1' to run it in the foreground instead." >&2
exit 1
}
# The lock fd is inherited by the detached run and held until it exits, so
# two concurrent starts cannot both pass the liveness check.
exec 9>> "$LOCK_FILE"
if command -v flock > /dev/null; then
flock -n 9 || {
echo "Failed to start: already running (pid $(command_pid || echo unknown)), one background command at a time" >&2
exit 1
}
fi
if is_running; then
echo "Failed to start: already running (pid $(command_pid)), one background command at a time" >&2
exit 1
fi
: > "$LOG_FILE"; rm -f "$PID_FILE"
# Not exec: the wrapper has to outlive node to record EXIT=, and stop has to
# signal node alone, never the process group this would otherwise share.
setsid sh -c '
node dist/command/command "$@" &
echo $! > "$PID_FILE"
wait $!
echo "EXIT=$?"
' command-background "$@" < /dev/null >> "$LOG_FILE" 2>&1 &
i=0
while [ ! -s "$PID_FILE" ] && [ "$i" -lt 10 ]; do i=$((i + 1)); sleep 1; done
is_running || {
echo "Failed to start: see $LOG_FILE" >&2
tail -n "$TAIL_LINES" "$LOG_FILE" >&2
exit 1
}
echo "Running (pid $(command_pid)), logging to $LOG_FILE"
detach_hint
# Release the lock here so only the run itself holds it: a reader left
# attached to the stream must not block the next start.
exec 9>&-
stream
}
logs() {
if is_running; then
echo "Running (pid $(command_pid)), following $LOG_FILE" >&2
detach_hint >&2
stream
fi
[ -f "$LOG_FILE" ] || { echo "Not running, no log at $LOG_FILE" >&2; exit 1; }
echo "Not running, last run $(last_run)" >&2
show_tail
}
stop() {
is_running || { echo "Not running, nothing to stop" >&2; rm -f "$PID_FILE"; exit 1; }
pid=$(command_pid)
case "${1:-}" in
"")
kill -TERM "$pid"
echo "SIGTERM sent to $pid, a command that handles it stops at its next boundary (exit 143)" >&2
sleep 1
show_tail
echo "Follow with 'yarn command:prod:background:logs'. Still stuck: 'stop --now'. Last resort: 'stop --force'." >&2
;;
--now)
kill -TERM "$pid"; sleep 2; kill -TERM "$pid" 2>/dev/null || true
echo "Second SIGTERM sent to $pid, immediate exit with the step in progress left unfinished" >&2
sleep 1
show_tail
;;
--force)
kill -KILL "$pid"
echo "SIGKILL sent to $pid, no graceful boundary so a multi-transaction command may leave partial work" >&2
show_tail
;;
*) echo "usage: stop [--now|--force]" >&2; exit 1 ;;
esac
}
case "${1:-}" in
start) shift; start "$@" ;;
logs) logs ;;
stop) shift; stop "$@" ;;
*) echo "usage: $0 {start <command> [args]|logs|stop [--now|--force]}" >&2; exit 1 ;;
esac

View File

@@ -0,0 +1,115 @@
import * as fs from 'fs';
import * as path from 'path';
import * as prettier from 'prettier';
import { ONELEET_TRUST_API_URL } from 'src/engine/core-modules/dpa/constants/oneleet-trust.constant';
import {
type Subprocessor,
type SubprocessorList,
} from 'src/engine/core-modules/dpa/types/subprocessor.type';
type TrustCenterResponse = {
subprocessors?: Array<{
name?: string;
services?: unknown[];
processingLocations?: unknown[];
processesPii?: boolean;
vendorUrl?: string;
}>;
};
const normalizeSubprocessors = (
raw: TrustCenterResponse['subprocessors'],
): Subprocessor[] =>
(raw ?? [])
.map((entry) => {
const subprocessor: Subprocessor = {
name: (entry.name ?? '').trim(),
services: (entry.services ?? [])
.map((service) => String(service).trim())
.filter(Boolean),
processingLocations: (entry.processingLocations ?? [])
.map((location) => String(location).trim())
.filter(Boolean),
processesPii: entry.processesPii === true,
};
const vendorUrl = (entry.vendorUrl ?? '').trim();
if (vendorUrl) {
subprocessor.vendorUrl = vendorUrl;
}
return subprocessor;
})
.filter((subprocessor) => subprocessor.name.length > 0)
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
const main = async (): Promise<void> => {
const dryRun = process.argv.includes('--dry-run');
// oxlint-disable-next-line no-console
console.log(`Fetching sub-processors from ${ONELEET_TRUST_API_URL}...`);
const response = await fetch(ONELEET_TRUST_API_URL);
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as TrustCenterResponse;
const subprocessors = normalizeSubprocessors(data.subprocessors);
if (subprocessors.length === 0) {
throw new Error(
'Trust Center returned no sub-processors; refusing to overwrite the list.',
);
}
// oxlint-disable-next-line no-console
console.log(
`Found ${subprocessors.length} sub-processors: ${subprocessors
.map((subprocessor) => subprocessor.name)
.join(', ')}`,
);
const result: SubprocessorList = { subprocessors };
const json = JSON.stringify(result, null, 2) + '\n';
if (dryRun) {
// oxlint-disable-next-line no-console
console.log('[DRY RUN] Would write subprocessors.json');
return;
}
const outputPath = path.resolve(
__dirname,
'..',
'src',
'engine',
'core-modules',
'dpa',
'constants',
'subprocessors.json',
);
const prettierConfig = await prettier.resolveConfig(outputPath);
const formatted = await prettier.format(json, {
...prettierConfig,
filepath: outputPath,
});
fs.writeFileSync(outputPath, formatted, 'utf-8');
// oxlint-disable-next-line no-console
console.log(`Wrote ${outputPath}`);
};
main().catch((error) => {
// oxlint-disable-next-line no-console
console.error('DPA sub-processor sync failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,4 @@
#!/bin/sh
export PG_DATABASE_URL=postgres://postgres:postgres@$PG_DATABASE_HOST:$PG_DATABASE_PORT/default
yarn database:init:prod
node dist/main

View File

@@ -0,0 +1,3 @@
#!/bin/sh
export PG_DATABASE_URL=postgres://postgres:postgres@$PG_DATABASE_HOST:$PG_DATABASE_PORT/default
node dist/queue-worker/queue-worker

View File

@@ -0,0 +1,74 @@
# Local SSL Certificate Generation Script
This Bash script helps generate self-signed SSL certificates for local development. It uses OpenSSL to create a root certificate authority, a domain certificate, and configures them for local usage.
## Features
- Generates a private key and root certificate.
- Creates a signed certificate for a specified domain.
- Adds the root certificate to the macOS keychain for trusted usage (macOS only).
- Customizable with default values for easier use.
## Requirements
- OpenSSL
## Usage
### Running the Script
To generate certificates using the default values:
```sh
./script.sh
```
### Specifying Custom Values
1. **Domain Name**: Specify the domain name for the certificate. Default is `localhost.com`.
2. **Root Certificate Name**: Specify a name for the root certificate. Default is `myRootCertificate`.
3. **Validity Days**: Specify the number of days the certificate is valid for. Default is `398` days.
#### Examples:
1. **Using Default Values**:
```sh
./script.sh
```
2. **Custom Domain Name**:
```sh
./script.sh example.com
```
3. **Custom Domain Name and Root Certificate Name**:
```sh
./script.sh example.com customRootCertificate
```
4. **Custom Domain Name, Root Certificate Name, and Validity Days**:
```sh
./script.sh example.com customRootCertificate 398
```
## Script Details
1. **Check if OpenSSL is Installed**: Ensures OpenSSL is installed before executing.
2. **Create Directory for Certificates**: Uses `~/certs/{domain}`.
3. **Generate Root Certificate**: Creates a root private key and certificate.
4. **Add Root Certificate to macOS Keychain**: Adds root certificate to macOS trusted store (requires admin privileges).
5. **Generate Domain Key**: Produces a private key for the domain.
6. **Create CSR**: Generates a Certificate Signing Request for the domain.
7. **Generate Signed Certificate**: Signs the domain certificate with the root certificate.
## Output Files
The generated files are stored in `~/certs/{domain}`:
- **Root certificate key**: `{root_cert_name}.key`
- **Root certificate**: `{root_cert_name}.pem`
- **Domain private key**: `{domain}.key`
- **Signed certificate**: `{domain}.crt`
## Notes
- If running on non-macOS systems, you'll need to manually add the root certificate to your trusted certificate store.
- Ensure that OpenSSL is installed and available in your PATH.

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Check if OpenSSL is installed
if ! command -v openssl &> /dev/null
then
echo "OpenSSL is not installed. Please install it before running this script."
exit
fi
# Default values
DOMAIN=${1:-localhost.com}
ROOT_CERT_NAME=${2:-myRootCertificate}
VALIDITY_DAYS=${3:-398} # Default is 825 days
CERTS_DIR=~/certs/$DOMAIN
# Create a directory to store the certificates
mkdir -p $CERTS_DIR
cd $CERTS_DIR
# Generate the private key for the Certificate Authority (CA)
openssl genrsa -aes256 -out ${ROOT_CERT_NAME}.key 2048
# Generate the root certificate for the CA
openssl req -x509 -new -nodes -key ${ROOT_CERT_NAME}.key -sha256 -days $VALIDITY_DAYS -out ${ROOT_CERT_NAME}.pem \
-subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=MyLocalCA"
# Add the root certificate to the macOS keychain (requires admin password)
if [[ "$OSTYPE" == "darwin"* ]]; then
sudo security add-trusted-cert -d -r trustRoot -k "/Library/Keychains/System.keychain" ${ROOT_CERT_NAME}.pem
fi
# Generate the private key for the provided domain
openssl genrsa -out $DOMAIN.key 2048
# Create a Certificate Signing Request (CSR) for the provided domain
openssl req -new -key $DOMAIN.key -out $DOMAIN.csr \
-subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=*.$DOMAIN"
# Create a configuration file for certificate extensions
cat > $DOMAIN.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = $DOMAIN
DNS.2 = *.$DOMAIN
EOF
# Sign the certificate with the CA
openssl x509 -req -in $DOMAIN.csr -CA ${ROOT_CERT_NAME}.pem -CAkey ${ROOT_CERT_NAME}.key -CAcreateserial \
-out $DOMAIN.crt -days $VALIDITY_DAYS -sha256 -extfile $DOMAIN.ext
echo "Certificates generated in the directory $CERTS_DIR:"
echo "- Root certificate: ${ROOT_CERT_NAME}.pem"
echo "- Domain private key: $DOMAIN.key"
echo "- Signed certificate: $DOMAIN.crt"
# Tips for usage
echo "To use these certificates with a local server, configure your server to use $DOMAIN.crt and $DOMAIN.key."

View File

@@ -0,0 +1,19 @@
import { i18n } from '@lingui/core';
import { compileMessage } from '@lingui/message-utils/compileMessage';
// Lingui 5.9+ throws when a translation function runs without an active
// locale. Production activates the global singleton in I18nService; unit
// tests bypass Nest bootstrap, so mirror that here. The messages compiler
// makes t`...` fall back to the English source text.
i18n.setMessagesCompiler(compileMessage);
i18n.load('en', {});
i18n.activate('en');
declare global {
namespace jest {
interface Matchers<R> {
toThrowError(error?: string | RegExp | Error): R;
toBeCalledTimes(expected: number): R;
}
}
}

View File

@@ -0,0 +1,156 @@
import {
type DynamicModule,
type MiddlewareConsumer,
Module,
RequestMethod,
} from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { ServeStaticModule } from '@nestjs/serve-static';
import { existsSync } from 'fs';
import { join } from 'path';
import { YogaDriver, type YogaDriverConfig } from '@graphql-yoga/nestjs';
import { SentryModule } from '@sentry/nestjs/setup';
import { ApiPath } from 'twenty-shared/types';
import { AdminPanelGraphQLApiModule } from 'src/engine/api/graphql/admin-panel-graphql-api.module';
import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module';
import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module';
import { GraphQLConfigService } from 'src/engine/api/graphql/graphql-config/graphql-config.service';
import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
import { McpMethodGuardMiddleware } from 'src/engine/api/mcp/middlewares/mcp-method-guard.middleware';
import { McpModule } from 'src/engine/api/mcp/mcp.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { WorkspaceAuthContextMiddleware } from 'src/engine/core-modules/auth/middlewares/workspace-auth-context.middleware';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { DataloaderModule } from 'src/engine/dataloaders/dataloader.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { ApiRequestContextMiddleware } from 'src/engine/core-modules/usage/middlewares/api-request-context.middleware';
import { CookieSessionCsrfMiddleware } from 'src/engine/middlewares/cookie-session-csrf.middleware';
import { GraphQLHydrateRequestFromTokenMiddleware } from 'src/engine/middlewares/graphql-hydrate-request-from-token.middleware';
import { MiddlewareModule } from 'src/engine/middlewares/middleware.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module';
import { RestCoreMiddleware } from 'src/engine/middlewares/rest-core.middleware';
import { TwentyOrmModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { UnhandledExceptionFilter } from 'src/filters/unhandled-exception.filter';
import { ModulesModule } from 'src/modules/modules.module';
import { ClickHouseModule } from './database/clickhouse/clickhouse.module';
import { CoreEngineModule } from './engine/core-modules/core-engine.module';
import { I18nModule } from './engine/core-modules/i18n/i18n.module';
// TODO: Remove this middleware when all the rest endpoints are migrated to TwentyORM
const MIGRATED_REST_METHODS = [
RequestMethod.DELETE,
RequestMethod.POST,
RequestMethod.PATCH,
RequestMethod.PUT,
RequestMethod.GET,
];
@Module({
imports: [
SentryModule.forRoot(),
GraphQLModule.forRootAsync<YogaDriverConfig>({
driver: YogaDriver,
imports: [GraphQLConfigModule, MetricsModule, DataloaderModule],
useClass: GraphQLConfigService,
}),
TwentyOrmModule,
ClickHouseModule,
CoreEngineModule,
ModulesModule,
// Needed for the user workspace middleware
WorkspaceCacheStorageModule,
CoreGraphQLApiModule,
MetadataGraphQLApiModule,
AdminPanelGraphQLApiModule,
RestApiModule,
McpModule,
MiddlewareModule,
JwtModule,
UserSessionModule,
WorkspaceMetadataVersionModule,
I18nModule,
...AppModule.getConditionalModules(),
],
providers: [
{
provide: APP_FILTER,
useClass: UnhandledExceptionFilter,
},
],
})
export class AppModule {
private static getConditionalModules(): DynamicModule[] {
const modules: DynamicModule[] = [];
const frontPath = join(__dirname, 'front');
if (existsSync(frontPath)) {
modules.push(
ServeStaticModule.forRoot({
rootPath: frontPath,
}),
);
}
// Messaque Queue explorer only for sync driver
// Maybe we don't need to conditionaly register the explorer, because we're creating a jobs module
// that will expose classes that are only used in the queue worker
return modules;
}
configure(consumer: MiddlewareConsumer) {
// Before any middleware that authenticates from the session cookie.
consumer
.apply(CookieSessionCsrfMiddleware)
// A cross-origin form post from the identity provider, authenticated on the
// assertion rather than the cookie.
.exclude({
path: `${ApiPath.Auth}/saml/callback/:identityProviderId`,
method: RequestMethod.POST,
})
.forRoutes({ path: '*path', method: RequestMethod.ALL });
consumer
.apply(
ApiRequestContextMiddleware,
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: ApiPath.GraphQL, method: RequestMethod.ALL });
consumer
.apply(
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: ApiPath.Metadata, method: RequestMethod.ALL });
consumer
.apply(
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: ApiPath.AdminPanel, method: RequestMethod.ALL });
consumer
.apply(ApiRequestContextMiddleware, McpMethodGuardMiddleware)
.forRoutes({ path: ApiPath.Mcp, method: RequestMethod.ALL });
for (const method of MIGRATED_REST_METHODS) {
consumer
.apply(
ApiRequestContextMiddleware,
RestCoreMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: `${ApiPath.Rest}/*path`, method });
}
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AppModule } from 'src/app.module';
import { DatabaseCommandModule } from 'src/database/commands/database-command.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
@Module({
imports: [
AppModule,
DatabaseCommandModule,
MessagingMessageCleanerModule,
ObjectMetadataModule,
FieldMetadataModule,
WorkspaceCleanerModule,
],
})
export class CommandModule {}

View File

@@ -0,0 +1,35 @@
import { CommandFactory } from 'nest-commander';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
import { CommandModule } from './command.module';
async function bootstrap() {
const errorHandler = (err: Error) => {
loggerService.error(err?.message, err?.name);
if (shouldCaptureException(err)) {
exceptionHandlerService.captureExceptions([err]);
}
process.exitCode = 1;
};
const app = await CommandFactory.createWithoutRunning(CommandModule, {
logger: ['error', 'warn', 'log'],
bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true',
errorHandler,
serviceErrorHandler: errorHandler,
});
const loggerService = app.get(LoggerService);
const exceptionHandlerService = app.get(ExceptionHandlerService);
app.useLogger(loggerService);
await CommandFactory.runApplication(app);
await app.close();
}
void bootstrap();

View File

@@ -0,0 +1,8 @@
import path from 'path';
// If the code is built through the testing module, assets are not output to the dist/assets directory.
const IS_BUILT_THROUGH_TESTING_MODULE = !__dirname.includes('/dist/');
export const ASSET_PATH = IS_BUILT_THROUGH_TESTING_MODULE
? path.resolve(__dirname, `../`)
: path.resolve(__dirname, `../assets`);

View File

@@ -0,0 +1,15 @@
import { msg } from '@lingui/core/macro';
import { CustomException } from 'src/utils/custom-exception';
export enum ClickHouseExceptionCode {
CLIENT_NOT_AVAILABLE = 'CLIENT_NOT_AVAILABLE',
}
export class ClickHouseException extends CustomException<ClickHouseExceptionCode> {
constructor(message: string, code: ClickHouseExceptionCode) {
super(message, code, {
userFriendlyMessage: msg`An unexpected error occurred.`,
});
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ClickHouseService } from './clickhouse.service';
@Module({
imports: [TwentyConfigModule],
providers: [ClickHouseService],
exports: [ClickHouseService],
})
export class ClickHouseModule {}

View File

@@ -0,0 +1,356 @@
import {
Injectable,
Logger,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import {
ClickHouseException,
ClickHouseExceptionCode,
} from 'src/database/clickhouse/clickhouse.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type ClickHouseInsertOptions = {
clientId?: string;
asyncInsertBusyTimeoutMaxMs?: number;
};
export type ClickHouseInsertResult =
| { success: true }
| { success: false; error: Error };
@Injectable()
export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
private mainClient: ClickHouseClient | undefined;
private clients: Map<string, ClickHouseClient> = new Map();
private isClientInitializing: Map<string, boolean> = new Map();
private readonly logger = new Logger(ClickHouseService.name);
constructor(private readonly twentyConfigService: TwentyConfigService) {
if (this.twentyConfigService.get('CLICKHOUSE_URL')) {
this.mainClient = createClient({
url: this.twentyConfigService.get('CLICKHOUSE_URL'),
compression: {
response: true,
request: true,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
}
}
public getMainClient(): ClickHouseClient | undefined {
return this.mainClient;
}
public async connectToClient(
clientId: string,
url?: string,
): Promise<ClickHouseClient | undefined> {
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
return undefined;
}
while (this.isClientInitializing.get(clientId)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (this.clients.has(clientId)) {
return this.clients.get(clientId);
}
this.isClientInitializing.set(clientId, true);
try {
const clientInstance = await this.createAndInitializeClient(url);
this.clients.set(clientId, clientInstance);
return clientInstance;
} catch (err) {
this.logger.error(
`Error connecting to ClickHouse client ${clientId}`,
err,
);
return undefined;
} finally {
this.isClientInitializing.delete(clientId);
}
}
private async createAndInitializeClient(
url?: string,
): Promise<ClickHouseClient> {
const client = createClient({
url: url ?? this.twentyConfigService.get('CLICKHOUSE_URL'),
compression: {
response: true,
request: true,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
await client.ping();
return client;
}
public async disconnectFromClient(clientId: string) {
if (!this.clients.has(clientId)) {
return;
}
const client = this.clients.get(clientId);
if (client) {
await client.close();
}
this.clients.delete(clientId);
}
async onModuleInit() {
if (this.mainClient) {
try {
await this.mainClient.ping();
} catch (err) {
this.logger.error('Error connecting to ClickHouse', err);
}
}
}
async onModuleDestroy() {
if (this.mainClient) {
await this.mainClient.close();
}
for (const [, client] of this.clients) {
await client.close();
}
}
// oxlint-disable-next-line typescript/no-explicit-any
public async insert<T extends Record<string, any>>(
table: string,
values: T[],
options: ClickHouseInsertOptions = {},
): Promise<ClickHouseInsertResult> {
try {
const client = options.clientId
? await this.connectToClient(options.clientId)
: this.mainClient;
if (!client) {
return {
success: false,
error: new Error(
`No ClickHouse client available${options.clientId ? ` for client ${options.clientId}` : ''}`,
),
};
}
await this.insertInChunks(client, table, values, {
chunkSize: 1000,
maxMemoryMB: 4,
asyncInsertBusyTimeoutMaxMs: options.asyncInsertBusyTimeoutMaxMs,
});
return { success: true };
} catch (err) {
this.logger.error('Error inserting data into ClickHouse', err);
return {
success: false,
error:
err instanceof Error
? err
: Object.assign(new Error(String(err)), { cause: err }),
};
}
}
public async select<T>(
query: string,
// oxlint-disable-next-line typescript/no-explicit-any
params?: Record<string, any>,
clientId?: string,
): Promise<T[]> {
try {
const client = clientId
? await this.connectToClient(clientId)
: this.mainClient;
if (!client) {
return [];
}
const resultSet = await client.query({
query,
format: 'JSONEachRow',
query_params: params,
});
const result = await resultSet.json<T>();
return Array.isArray(result) ? result : [];
} catch (err) {
this.logger.error('Error executing select query in ClickHouse', err);
return [];
}
}
public async selectOrThrow<T>(
query: string,
// oxlint-disable-next-line typescript/no-explicit-any
params?: Record<string, any>,
): Promise<T[]> {
if (!this.mainClient) {
throw new ClickHouseException(
'ClickHouse client is not available',
ClickHouseExceptionCode.CLIENT_NOT_AVAILABLE,
);
}
const resultSet = await this.mainClient.query({
query,
format: 'JSONEachRow',
query_params: params,
});
const result = await resultSet.json<T>();
return Array.isArray(result) ? result : [];
}
public async createDatabase(databaseName: string): Promise<boolean> {
try {
if (!this.mainClient) {
return false;
}
await this.mainClient.exec({
query: `CREATE DATABASE IF NOT EXISTS ${databaseName}`,
});
return true;
} catch (err) {
this.logger.error('Error creating database in ClickHouse', err);
return false;
}
}
public async dropDatabase(databaseName: string): Promise<boolean> {
try {
if (!this.mainClient) {
return false;
}
await this.mainClient.exec({
query: `DROP DATABASE IF EXISTS ${databaseName}`,
});
return true;
} catch (err) {
this.logger.error('Error dropping database in ClickHouse', err);
return false;
}
}
public async executeCommand(
query: string,
// oxlint-disable-next-line typescript/no-explicit-any
params?: Record<string, any>,
clientId?: string,
): Promise<boolean> {
try {
const client = clientId
? await this.connectToClient(clientId)
: this.mainClient;
if (!client) {
return false;
}
await client.command({
query,
query_params: params,
});
return true;
} catch (err) {
this.logger.error('Error executing command in ClickHouse', err);
return false;
}
}
// oxlint-disable-next-line typescript/no-explicit-any
private async insertInChunks<T extends Record<string, any>>(
client: ClickHouseClient,
table: string,
values: T[],
options: {
chunkSize?: number;
maxMemoryMB?: number;
asyncInsertBusyTimeoutMaxMs?: number;
} = {},
): Promise<void> {
const chunkSize = options.chunkSize ?? 1000;
const maxMemoryMB = options.maxMemoryMB;
let chunk: T[] = [];
let currentSizeBytes = 0;
const flush = async () => {
if (chunk.length === 0) return;
await client.insert({
table,
values: chunk,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
...(options.asyncInsertBusyTimeoutMaxMs !== undefined
? {
async_insert_busy_timeout_max_ms:
options.asyncInsertBusyTimeoutMaxMs,
}
: {}),
wait_for_async_insert: 1,
},
});
chunk = [];
currentSizeBytes = 0;
};
for (const row of values) {
const rowSize = Buffer.byteLength(JSON.stringify(row));
chunk.push(row);
currentSizeBytes += rowSize;
const currentSizeMB = currentSizeBytes / 1024 / 1024;
if (
chunk.length >= chunkSize ||
(maxMemoryMB !== undefined && currentSizeMB >= maxMemoryMB)
) {
await flush();
}
}
await flush();
}
}

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS workspaceEvent
(
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String NOT NULL,
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, event, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS pageview
(
`name` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String DEFAULT '',
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, name, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS objectEvent
(
`event` LowCardinality(String) NOT NULL,
`timestamp` DateTime64(3) NOT NULL,
`userId` String DEFAULT '',
`workspaceId` String NOT NULL,
`recordId` String NOT NULL,
`objectMetadataId` String NOT NULL,
`properties` JSON,
`isCustom` Boolean DEFAULT FALSE
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, event, userId)
TTL timestamp + INTERVAL 3 YEAR DELETE;

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS usageEvent
(
`timestamp` DateTime64(3) NOT NULL,
`workspaceId` String NOT NULL,
`userWorkspaceId` String DEFAULT '',
`resourceType` LowCardinality(String) NOT NULL,
`operationType` LowCardinality(String) NOT NULL,
`quantity` Int64 NOT NULL DEFAULT 0,
`unit` LowCardinality(String) NOT NULL DEFAULT 'CREDIT',
`creditsUsedMicro` Int64 NOT NULL DEFAULT 0,
`resourceId` String DEFAULT '',
`resourceContext` String DEFAULT '',
`metadata` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, resourceType, operationType, userWorkspaceId, resourceId)
TTL timestamp + INTERVAL 3 YEAR DELETE;

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS applicationLog
(
`timestamp` DateTime64(3) NOT NULL,
`workspaceId` String NOT NULL,
`applicationId` String DEFAULT '',
`logicFunctionId` String DEFAULT '',
`logicFunctionName` String DEFAULT '',
`executionId` String DEFAULT '',
`level` LowCardinality(String) DEFAULT 'INFO',
`message` String NOT NULL,
`properties` JSON
)
ENGINE = MergeTree
ORDER BY (workspaceId, timestamp, applicationId, logicFunctionId)
TTL timestamp + INTERVAL 30 DAY DELETE;

View File

@@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS
usageEvent_v2 (
`timestamp` DateTime64 (3) NOT NULL,
`workspaceId` String NOT NULL,
`periodStart` DateTime64 (3),
`userWorkspaceId` String DEFAULT '',
`resourceType` LowCardinality (String) NOT NULL,
`operationType` LowCardinality (String) NOT NULL,
`quantity` Int64 NOT NULL DEFAULT 0,
`unit` LowCardinality (String) NOT NULL DEFAULT 'CREDIT',
`creditsUsedMicro` Int64 NOT NULL DEFAULT 0,
`resourceId` String DEFAULT '',
`resourceContext` String DEFAULT '',
`metadata` JSON
) ENGINE = MergeTree
PARTITION BY
toYYYYMM (timestamp)
ORDER BY
(workspaceId, timestamp, userWorkspaceId)
PRIMARY KEY (workspaceId, timestamp)
TTL toDateTime(timestamp) + INTERVAL 3 YEAR DELETE;
INSERT INTO
usageEvent_v2 (timestamp, workspaceId, userWorkspaceId, resourceType, operationType, quantity, unit, creditsUsedMicro, resourceId, resourceContext, metadata)
SELECT
timestamp,
workspaceId,
userWorkspaceId,
resourceType,
operationType,
quantity,
unit,
creditsUsedMicro,
resourceId,
resourceContext,
metadata
FROM
usageEvent;
RENAME TABLE usageEvent TO usageEvent_old;
RENAME TABLE usageEvent_v2 TO usageEvent;
DROP TABLE IF EXISTS usageEvent_old;
ALTER TABLE usageEvent ADD PROJECTION IF NOT EXISTS billing_by_workspace_period (
SELECT
workspaceId,
periodStart,
sum(creditsUsedMicro) AS totalCreditsUsedMicro
GROUP BY
workspaceId, periodStart
);

View File

@@ -0,0 +1,10 @@
ALTER TABLE usageEvent ADD COLUMN IF NOT EXISTS apiKeyId String DEFAULT '';
ALTER TABLE usageEvent ADD COLUMN IF NOT EXISTS applicationId String DEFAULT '';
ALTER TABLE usageEvent ADD COLUMN IF NOT EXISTS agentId String DEFAULT '';
ALTER TABLE usageEvent ADD COLUMN IF NOT EXISTS workflowId String DEFAULT '';
ALTER TABLE usageEvent ADD COLUMN IF NOT EXISTS logicFunctionId String DEFAULT '';

View File

@@ -0,0 +1,19 @@
ALTER TABLE usageEvent DROP PROJECTION IF EXISTS consumption_by_scope;
ALTER TABLE usageEvent ADD PROJECTION IF NOT EXISTS consumption_by_scope (
SELECT
workspaceId,
resourceType,
toStartOfDay(timestamp, 'UTC'),
operationType,
userWorkspaceId,
apiKeyId,
applicationId,
agentId,
workflowId,
logicFunctionId,
sum(creditsUsedMicro) AS creditsUsedMicro,
sum(quantity) AS quantity
GROUP BY
workspaceId, resourceType, toStartOfDay(timestamp, 'UTC'), operationType, userWorkspaceId, apiKeyId, applicationId, agentId, workflowId, logicFunctionId
);

View File

@@ -0,0 +1,143 @@
/* oxlint-disable no-console */
import fs from 'fs';
import path from 'path';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import { config } from 'dotenv';
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const clickHouseUrl = () => {
const url = process.env.CLICKHOUSE_URL;
if (url) return url;
throw new Error(
'CLICKHOUSE_URL environment variable is not set. Please set it to the ClickHouse URL.',
);
};
async function ensureDatabaseExists() {
const [url, database] = clickHouseUrl().split(/\/(?=[^/]*$)/);
const client = createClient({
url,
log: { level: ClickHouseLogLevel.OFF },
});
try {
await client.command({
query: `CREATE DATABASE IF NOT EXISTS "${database}"`,
});
} catch {
// It may fail due to permissions, but the database already exists
} finally {
await client.close();
}
}
async function ensureMigrationTable(client: ClickHouseClient) {
await client.command({
query: `
CREATE TABLE IF NOT EXISTS _migration (
filename String,
applied_at DateTime DEFAULT now()
) ENGINE = MergeTree()
ORDER BY filename;
`,
});
}
async function hasMigrationBeenRun(
filename: string,
client: ClickHouseClient,
): Promise<boolean> {
const resultSet = await client.query({
query: `SELECT count() as count FROM _migration WHERE filename = {filename:String}`,
query_params: { filename },
format: 'JSON',
});
const result = await resultSet.json<{ count: number }>();
return result.data[0].count > 0;
}
async function recordMigration(filename: string, client: ClickHouseClient) {
await client.insert({
table: '_migration',
values: [{ filename }],
format: 'JSONEachRow',
});
}
async function runMigrations() {
const dir = path.join(__dirname);
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.sql'));
if (files.length === 0) {
throw new Error(
`No .sql migration file in ${dir}: the build did not copy the ClickHouse migrations`,
);
}
await ensureDatabaseExists();
const client = createClient({
url: clickHouseUrl(),
clickhouse_settings: {
allow_experimental_json_type: 1,
},
log: { level: ClickHouseLogLevel.OFF },
});
await ensureMigrationTable(client);
for (const file of files) {
const alreadyRun = await hasMigrationBeenRun(file, client);
if (alreadyRun) {
console.log(`✔︎ Skipping already applied migration: ${file}`);
continue;
}
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
console.log(`⚡ Running ${file}...`);
const statements = sql
.split(';')
.map((stmt) => stmt.trim())
.filter(
(stmt) =>
stmt.length > 0 && !stmt.startsWith('--') && !stmt.match(/^[\s-]*$/),
);
for (const statement of statements) {
const cleanedStatement = statement
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.trim();
if (cleanedStatement.length > 0) {
await client.command({ query: cleanedStatement });
}
}
await recordMigration(file, client);
}
console.log('✅ All migrations applied.');
await client.close();
}
runMigrations().catch((err) => {
console.error('Migration error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,248 @@
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
import { formatDateTimeForClickHouse } from 'src/database/clickhouse/utils/format-date-time-for-clickhouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
export type ObjectEventFixture = GenericTrackEvent & {
recordId: string;
objectMetadataId: string;
isCustom?: boolean;
};
export type UsageEventFixture = {
timestamp: string;
workspaceId: string;
periodStart: string;
userWorkspaceId: string;
resourceType: string;
operationType: string;
quantity: number;
unit: string;
creditsUsedMicro: number;
resourceId: string;
resourceContext: string;
metadata: Record<string, never>;
};
export const workspaceEventFixtures: Array<GenericTrackEvent> = [
{
type: 'track',
event: CUSTOM_DOMAIN_ACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
{
type: 'track',
event: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
];
export const objectEventFixtures: Array<ObjectEventFixture> = [
{
type: 'track',
event: OBJECT_RECORD_CREATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
{
type: 'track',
event: OBJECT_RECORD_UPDATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
{
type: 'track',
event: OBJECT_RECORD_DELETED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
];
const buildUsageEventFixtures = (): UsageEventFixture[] => {
const now = new Date();
const fixtures: UsageEventFixture[] = [];
const users = [
USER_WORKSPACE_DATA_SEED_IDS.TIM,
USER_WORKSPACE_DATA_SEED_IDS.JANE,
USER_WORKSPACE_DATA_SEED_IDS.JONY,
USER_WORKSPACE_DATA_SEED_IDS.PHIL,
];
// Weight per user so the breakdown isn't uniform
const userWeights = [1.0, 0.6, 0.3, 0.15];
const aiModelIds = [
'anthropic/claude-opus-4-6',
'openai/gpt-5.4',
'openai/gpt-5.4-mini',
'google/gemini-2.5-pro',
];
const operations: {
resourceType: string;
operationType: string;
baseCreditsMicro: number;
baseQuantity: number;
unit: string;
modelIds?: string[];
}[] = [
{
resourceType: 'AI',
operationType: 'AI_CHAT_TOKEN',
baseCreditsMicro: 5000,
baseQuantity: 1200,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'AI',
operationType: 'AI_WORKFLOW_TOKEN',
baseCreditsMicro: 3500,
baseQuantity: 800,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'WORKFLOW',
operationType: 'WORKFLOW_EXECUTION',
baseCreditsMicro: 12000,
baseQuantity: 1,
unit: 'INVOCATION',
},
{
resourceType: 'WORKFLOW',
operationType: 'CODE_EXECUTION',
baseCreditsMicro: 3000,
baseQuantity: 1,
unit: 'INVOCATION',
},
{
resourceType: 'AI',
operationType: 'WEB_SEARCH',
baseCreditsMicro: 7000,
baseQuantity: 1,
unit: 'INVOCATION',
},
];
// Pseudo-random using a seed for reproducibility across runs
let rngState = 42;
const nextRandom = () => {
rngState = (rngState * 1664525 + 1013904223) & 0x7fffffff;
return rngState / 0x7fffffff;
};
for (let daysAgo = 34; daysAgo >= 0; daysAgo--) {
const day = new Date(now);
day.setDate(day.getDate() - daysAgo);
day.setHours(0, 0, 0, 0);
// Weekdays have more activity than weekends
const dayOfWeek = day.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const dayMultiplier = isWeekend ? 0.3 : 1.0;
// Gradual ramp-up over the month (more recent = more usage)
const recencyMultiplier = 0.5 + 0.5 * ((35 - daysAgo) / 35);
for (let userIdx = 0; userIdx < users.length; userIdx++) {
const userWeight = userWeights[userIdx];
for (const op of operations) {
if (nextRandom() < 0.25) {
continue;
}
const eventsCount = Math.max(
1,
Math.round(
(1 + nextRandom() * 4) *
dayMultiplier *
recencyMultiplier *
userWeight,
),
);
for (let eventIdx = 0; eventIdx < eventsCount; eventIdx++) {
const hour = Math.floor(9 + nextRandom() * 9); // 9am–6pm
const minute = Math.floor(nextRandom() * 60);
const second = Math.floor(nextRandom() * 60);
const eventDate = new Date(day);
eventDate.setHours(
hour,
minute,
second,
Math.floor(nextRandom() * 1000),
);
const jitter = 0.5 + nextRandom();
const resourceContext = op.modelIds
? op.modelIds[Math.floor(nextRandom() * op.modelIds.length)]
: '';
fixtures.push({
timestamp: formatDateTimeForClickHouse(eventDate),
workspaceId: SEED_APPLE_WORKSPACE_ID,
periodStart: formatDateTimeForClickHouse(
new Date(
Date.UTC(
eventDate.getUTCFullYear(),
eventDate.getUTCMonth(),
1,
),
),
),
userWorkspaceId: users[userIdx],
resourceType: op.resourceType,
operationType: op.operationType,
quantity: Math.round(op.baseQuantity * jitter),
unit: op.unit,
creditsUsedMicro: Math.round(op.baseCreditsMicro * jitter),
resourceId: '',
resourceContext,
metadata: {},
});
}
}
}
}
return fixtures;
};
export const usageEventFixtures: UsageEventFixture[] =
buildUsageEventFixtures();

View File

@@ -0,0 +1,61 @@
/* oxlint-disable no-console */
import { createClient, ClickHouseLogLevel } from '@clickhouse/client';
import { config } from 'dotenv';
import {
objectEventFixtures,
usageEventFixtures,
workspaceEventFixtures,
} from './fixtures';
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const client = createClient({
url: process.env.CLICKHOUSE_URL,
log: { level: ClickHouseLogLevel.OFF },
});
async function seedEvents() {
try {
console.log(
`⚡ Seeding ${workspaceEventFixtures.length} workspace events...`,
);
await client.insert({
table: 'workspaceEvent',
values: workspaceEventFixtures,
format: 'JSONEachRow',
});
console.log(`⚡ Seeding ${objectEventFixtures.length} object events...`);
await client.insert({
table: 'objectEvent',
values: objectEventFixtures,
format: 'JSONEachRow',
});
console.log(`⚡ Seeding ${usageEventFixtures.length} usage events...`);
await client.insert({
table: 'usageEvent',
values: usageEventFixtures,
format: 'JSONEachRow',
});
console.log('✅ All events seeded successfully');
} catch (error) {
console.error('Error seeding events:', error);
throw error;
} finally {
await client.close();
}
}
seedEvents().catch((err) => {
console.error('Seeding error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,2 @@
export const formatDateForClickHouse = (date: Date): string =>
date.toISOString().slice(0, 10);

View File

@@ -0,0 +1,7 @@
// ClickHouse DateTime64(3) expects YYYY-MM-DD HH:mm:ss.SSS (no 'T' separator,
// no 'Z' suffix), while toISOString() returns YYYY-MM-DDTHH:mm:ss.SSSZ.
export const formatDateTimeForClickHouse = (date: Date | string): string => {
const iso = typeof date === 'string' ? date : date.toISOString();
return `${iso.slice(0, 10)} ${iso.slice(11, 23)}`;
};

View File

@@ -0,0 +1,32 @@
const TRANSIENT_NETWORK_ERROR_CODES = new Set([
'ECONNRESET',
'ECONNABORTED',
'EPIPE',
'ETIMEDOUT',
]);
const TRANSIENT_NETWORK_ERROR_MESSAGES = new Set([
'Timeout error.',
'socket hang up',
]);
export const isTransientClickHouseNetworkError = (
error: unknown,
): error is Error => {
if (!(error instanceof Error)) {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
if (
(code !== undefined && TRANSIENT_NETWORK_ERROR_CODES.has(code)) ||
TRANSIENT_NETWORK_ERROR_MESSAGES.has(error.message)
) {
return true;
}
return isTransientClickHouseNetworkError(
(error as Error & { cause?: unknown }).cause,
);
};

View File

@@ -0,0 +1,4 @@
// ClickHouse returns DateTime64 values as naive strings (YYYY-MM-DD HH:mm:ss.SSS) in UTC.
// Parse them as UTC explicitly, otherwise `new Date` assumes the server's local timezone.
export const parseClickHouseDateTime = (value: string): Date =>
new Date(`${value.replace(' ', 'T')}Z`);

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CommandShutdownService } from 'src/database/commands/command-runners/command-shutdown.service';
@Module({
providers: [CommandShutdownService],
exports: [CommandShutdownService],
})
export class CommandShutdownModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'] as const;
type ShutdownSignal = (typeof SHUTDOWN_SIGNALS)[number];
const EXIT_CODE_BY_SIGNAL: Record<ShutdownSignal, number> = {
SIGINT: 130,
SIGTERM: 143,
};
@Injectable()
export class CommandShutdownService {
private readonly logger = new Logger(CommandShutdownService.name);
private receivedSignal: ShutdownSignal | undefined;
private isListening = false;
listenToShutdownSignals(): void {
if (this.isListening) {
return;
}
this.isListening = true;
for (const shutdownSignal of SHUTDOWN_SIGNALS) {
process.on(shutdownSignal, () => this.handleSignal(shutdownSignal));
}
}
isShutdownRequested(): boolean {
return isDefined(this.receivedSignal);
}
private handleSignal(shutdownSignal: ShutdownSignal): void {
if (this.isShutdownRequested()) {
this.logger.warn(
`Received ${shutdownSignal} again, exiting immediately. ` +
'The step in progress is left unfinished, rerun the command to resume from the last recorded step.',
);
process.exit(EXIT_CODE_BY_SIGNAL[shutdownSignal]);
}
this.receivedSignal = shutdownSignal;
process.exitCode = EXIT_CODE_BY_SIGNAL[shutdownSignal];
this.logger.warn(
`Received ${shutdownSignal}, finishing the step in progress then stopping. ` +
`Send ${shutdownSignal} again to exit immediately.`,
);
}
}

View File

@@ -0,0 +1,67 @@
import { type Logger } from '@nestjs/common';
import chalk from 'chalk';
import { CommandRunner, Option } from 'nest-commander';
import { CommandLogger } from 'src/database/commands/logger';
export type MigrationCommandOptions = {
dryRun?: boolean;
verbose?: boolean;
};
export abstract class MigrationCommandRunner extends CommandRunner {
protected logger: CommandLogger | Logger;
constructor() {
super();
this.logger = new CommandLogger({
verbose: false,
constructorName: this.constructor.name,
});
}
@Option({
flags: '-d, --dry-run',
description: 'Simulate the command without making actual changes',
required: false,
})
parseDryRun(): boolean {
return true;
}
@Option({
flags: '-v, --verbose',
description: 'Verbose output',
required: false,
})
parseVerbose(): boolean {
return true;
}
override async run(
passedParams: string[],
options: MigrationCommandOptions,
): Promise<void> {
if (options.verbose) {
this.logger = new CommandLogger({
verbose: true,
constructorName: this.constructor.name,
});
}
try {
await this.runMigrationCommand(passedParams, options);
this.logger.log(chalk.blue('Command completed!'));
} catch (error) {
this.logger.error(chalk.red(`Command failed`));
throw error;
}
}
protected abstract runMigrationCommand(
passedParams: string[],
options: MigrationCommandOptions,
): Promise<void>;
}

View File

@@ -0,0 +1,20 @@
import { PROVISIONED_WORKSPACE_ACTIVATION_STATUSES } from 'twenty-shared/workspace';
import {
WorkspaceCommandRunner,
type WorkspaceCommandOptions,
} from 'src/database/commands/command-runners/workspace.command-runner';
import { type WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
export type ProvisionedWorkspaceCommandOptions = WorkspaceCommandOptions;
export abstract class ProvisionedWorkspaceCommandRunner<
Options extends ProvisionedWorkspaceCommandOptions =
ProvisionedWorkspaceCommandOptions,
> extends WorkspaceCommandRunner<Options> {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
) {
super(workspaceIteratorService, PROVISIONED_WORKSPACE_ACTIVATION_STATUSES);
}
}

View File

@@ -0,0 +1,18 @@
import { type WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Raw } from 'typeorm';
// TODO: drop the ::text cast and use In() once upgrades from versions before 2.22 (CREATED enum migration) are no longer supported
export const activationStatusIn = (statuses: WorkspaceActivationStatus[]) =>
Raw(
(alias) => {
const quotedAlias = alias
.split('.')
.map((aliasPart) => `"${aliasPart.replace(/"/g, '')}"`)
.join('.');
return `${quotedAlias}::text IN (:...activationStatusValues)`;
},
{
activationStatusValues: statuses,
},
);

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommandShutdownModule } from 'src/database/commands/command-runners/command-shutdown.module';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [CommandShutdownModule, TypeOrmModule.forFeature([WorkspaceEntity])],
providers: [WorkspaceIteratorService],
exports: [WorkspaceIteratorService],
})
export class WorkspaceIteratorModule {}

View File

@@ -0,0 +1,221 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { isNonEmptyString } from '@sniptt/guards';
import {
PROVISIONED_WORKSPACE_ACTIVATION_STATUSES,
WorkspaceActivationStatus,
} from 'twenty-shared/workspace';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, MoreThanOrEqual, Raw, Repository } from 'typeorm';
import { CommandShutdownService } from 'src/database/commands/command-runners/command-shutdown.service';
import { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceOrmManager } from 'src/engine/twenty-orm/workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
export type WorkspaceIteratorShard = {
index: number;
total: number;
};
export type WorkspaceIteratorArgs = {
workspaceIds?: string[];
activationStatuses?: WorkspaceActivationStatus[];
startFromWorkspaceId?: string;
workspaceCountLimit?: number;
shard?: WorkspaceIteratorShard;
dryRun?: boolean;
callback: (context: WorkspaceIteratorContext) => Promise<void>;
};
export type WorkspaceIteratorContext = {
workspaceId: string;
databaseSchema?: string;
dataSource?: DataSource;
index: number;
total: number;
};
export type WorkspaceIteratorReport = {
fail: {
workspaceId: string;
error: Error;
}[];
success: {
workspaceId: string;
}[];
interrupted: boolean;
};
const DEFAULT_ACTIVATION_STATUSES = PROVISIONED_WORKSPACE_ACTIVATION_STATUSES;
@Injectable()
export class WorkspaceIteratorService {
private readonly logger = new Logger(WorkspaceIteratorService.name);
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly workspaceOrmManager: WorkspaceOrmManager,
private readonly commandShutdownService: CommandShutdownService,
) {}
listenToShutdownSignals(): void {
this.commandShutdownService.listenToShutdownSignals();
}
async iterate(args: WorkspaceIteratorArgs): Promise<WorkspaceIteratorReport> {
const { callback, ...options } = args;
const report: WorkspaceIteratorReport = {
fail: [],
success: [],
interrupted: false,
};
const workspaceIdsToProcess =
options.workspaceIds && options.workspaceIds.length > 0
? options.workspaceIds
: await this.fetchWorkspaceIds(options);
if (options.dryRun) {
this.logger.log(chalk.yellow('Dry run mode: No changes will be applied'));
}
for (const [index, workspaceId] of workspaceIdsToProcess.entries()) {
if (this.commandShutdownService.isShutdownRequested()) {
this.logger.warn(
`Shutdown requested, stopping before workspace ${workspaceId}. ` +
`${workspaceIdsToProcess.length - index} workspace(s) left untouched.`,
);
report.interrupted = true;
break;
}
this.logger.log(
`Running on workspace ${workspaceId} ${index + 1}/${workspaceIdsToProcess.length}`,
);
try {
const authContext = buildSystemAuthContext(workspaceId);
await this.workspaceOrmManager.executeInWorkspaceContext(async () => {
const workspace = await this.workspaceRepository.findOne({
select: ['databaseSchema'],
where: { id: workspaceId },
});
const dataSource = isNonEmptyString(workspace?.databaseSchema)
? this.coreDataSource
: undefined;
if (!isDefined(dataSource)) {
this.logger.warn(
`Could not retrieve a workspace data source for workspace ${workspaceId} ` +
`(index ${index + 1}/${workspaceIdsToProcess.length}): ` +
`workspaceRowFound=${isDefined(workspace)}, ` +
`databaseSchema=${JSON.stringify(workspace?.databaseSchema ?? null)}`,
);
}
await callback({
workspaceId,
databaseSchema: workspace?.databaseSchema ?? undefined,
dataSource,
index,
total: workspaceIdsToProcess.length,
});
}, authContext);
report.success.push({ workspaceId });
} catch (error: unknown) {
report.fail.push({ error: error as Error, workspaceId });
}
}
report.fail.forEach(({ error, workspaceId }) => {
this.logger.error(
`Error in workspace ${workspaceId}: ${error.message}`,
error.stack,
);
if (error instanceof WorkspaceMigrationRunnerException && error.errors) {
for (const [label, innerError] of Object.entries(error.errors)) {
if (!isDefined(innerError)) continue;
if (innerError instanceof Error) {
this.logger.error(
`Caused by ${label} in workspace ${workspaceId}: ${innerError.message}`,
innerError.stack,
);
} else {
this.logger.error(
`Caused by ${label} in workspace ${workspaceId}: ${String(innerError)}`,
);
}
}
}
});
return report;
}
private async fetchWorkspaceIds(
options: Pick<
WorkspaceIteratorArgs,
| 'activationStatuses'
| 'startFromWorkspaceId'
| 'workspaceCountLimit'
| 'shard'
>,
): Promise<string[]> {
const activationStatuses =
options.activationStatuses ?? DEFAULT_ACTIVATION_STATUSES;
const shard = options.shard;
if (isDefined(shard)) {
if (isDefined(options.startFromWorkspaceId)) {
throw new Error(
'Cannot combine shard with startFromWorkspaceId in workspace iterator',
);
}
if (shard.index < 0 || shard.index >= shard.total || shard.total > 256) {
throw new Error(
`Invalid workspace iterator shard ${shard.index}/${shard.total}`,
);
}
}
const workspaces = await this.workspaceRepository.find({
select: ['id'],
where: {
activationStatus: activationStatusIn(activationStatuses),
...(options.startFromWorkspaceId
? { id: MoreThanOrEqual(options.startFromWorkspaceId) }
: {}),
...(isDefined(shard)
? {
id: Raw(
(alias) =>
`mod(get_byte(uuid_send(${alias}), 0), :shardTotal) = :shardIndex`,
{ shardTotal: shard.total, shardIndex: shard.index },
),
}
: {}),
},
order: { id: 'ASC' },
take: options.workspaceCountLimit,
});
return workspaces.map((workspace) => workspace.id);
}
}

View File

@@ -0,0 +1,152 @@
import chalk from 'chalk';
import { CommandRunner, Option } from 'nest-commander';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type DataSource } from 'typeorm';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { CommandLogger } from 'src/database/commands/logger';
export type WorkspaceCommandOptions = {
workspaceId?: Set<string>;
startFromWorkspaceId?: string;
workspaceCountLimit?: number;
dryRun?: boolean;
verbose?: boolean;
};
export type RunOnWorkspaceArgs = {
options: WorkspaceCommandOptions;
workspaceId: string;
dataSource?: DataSource;
index: number;
total: number;
};
export abstract class WorkspaceCommandRunner<
Options extends WorkspaceCommandOptions = WorkspaceCommandOptions,
> extends CommandRunner {
protected logger: CommandLogger;
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
protected readonly activationStatuses: WorkspaceActivationStatus[],
) {
super();
this.logger = new CommandLogger({
verbose: false,
constructorName: this.constructor.name,
});
}
@Option({
flags: '-d, --dry-run',
description: 'Simulate the command without making actual changes',
required: false,
})
parseDryRun(): boolean {
return true;
}
@Option({
flags: '-v, --verbose',
description: 'Verbose output',
required: false,
})
parseVerbose(): boolean {
return true;
}
@Option({
flags: '--start-from-workspace-id [workspace_id]',
description:
'Start from a specific workspace id. Workspaces are processed in ascending order of id.',
required: false,
})
parseStartFromWorkspaceId(val: string): string {
return val;
}
@Option({
flags: '--workspace-count-limit [count]',
description:
'Limit the number of workspaces to process. Workspaces are processed in ascending order of id.',
required: false,
})
parseWorkspaceCountLimit(val: string): number {
const limit = parseInt(val);
if (isNaN(limit)) {
throw new Error('Workspace count limit must be a number');
}
if (limit <= 0) {
throw new Error('Workspace count limit must be greater than 0');
}
return limit;
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description:
'workspace id. Command runs on all workspaces matching the activation statuses if not provided.',
required: false,
})
parseWorkspaceId(val: string, previous?: Set<string>): Set<string> {
const accumulator = previous ?? new Set<string>();
accumulator.add(val);
return accumulator;
}
override async run(_passedParams: string[], options: Options): Promise<void> {
if (options.verbose) {
this.logger = new CommandLogger({
verbose: true,
constructorName: this.constructor.name,
});
}
this.workspaceIteratorService.listenToShutdownSignals();
try {
const report = await this.workspaceIteratorService.iterate({
workspaceIds:
options.workspaceId && options.workspaceId.size > 0
? Array.from(options.workspaceId)
: undefined,
activationStatuses: this.activationStatuses,
startFromWorkspaceId: options.startFromWorkspaceId,
workspaceCountLimit: options.workspaceCountLimit,
dryRun: options.dryRun,
callback: async (context) => {
await this.runOnWorkspace({
options,
workspaceId: context.workspaceId,
dataSource: context.dataSource,
index: context.index,
total: context.total,
});
},
});
if (report.interrupted) {
this.logger.warn(
chalk.yellow(
'Command interrupted before processing every workspace. Rerun it to process the remaining ones.',
),
);
return;
}
this.logger.log(chalk.blue('Command completed!'));
} catch (error) {
this.logger.error(chalk.red(`Command failed`));
throw error;
}
}
public abstract runOnWorkspace(args: RunOnWorkspaceArgs): Promise<void>;
}

View File

@@ -0,0 +1,280 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { ApplicationRecurringChargeCronCommand } from 'src/engine/core-modules/billing/app-billing/crons/commands/application-recurring-charge.cron.command';
import { BillingReminderCronCommand } from 'src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command';
import { CheckEmailingDomainVerificationCronCommand } from 'src/engine/core-modules/emailing-domain/crons/commands/check-emailing-domain-verification.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/organization-key-validation.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { PendingFileCleanupCronCommand } from 'src/engine/core-modules/file/file-upload/crons/commands/pending-file-cleanup.cron.command';
import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command';
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserSessionCleanupCronCommand } from 'src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { WebhookSubscriptionRenewalCronCommand } from 'src/modules/connected-account/webhook-subscription-manager/crons/commands/webhook-subscription-renewal.cron.command';
import { EmailingOngoingStaleCronCommand } from 'src/modules/emailing/crons/commands/emailing-ongoing-stale.cron.command';
import { ReconcileCampaignStatsCronCommand } from 'src/modules/emailing/crons/commands/reconcile-campaign-stats.cron.command';
import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command';
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command';
import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command';
import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command';
import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command';
import { CalendarRelaunchFailedCalendarChannelsCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-relaunch-failed-calendar-channels.cron.command';
import { MessagingMessageListFetchCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-message-list-fetch.cron.command';
import { MessagingMessagesImportCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-messages-import.cron.command';
import { MessagingOngoingStaleCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-ongoing-stale.cron.command';
import { MessagingRelaunchFailedMessageChannelsCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-relaunch-failed-message-channels.cron.command';
import { WorkflowCoreConsistencyCronCommand } from 'src/modules/workflow/workflow-core-consistency/crons/commands/workflow-core-consistency-cron.command';
import { WorkflowCleanWorkflowRunsCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-clean-workflow-runs.cron.command';
import { WorkflowHandleStaledRunsCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-handle-staled-runs.cron.command';
import { WorkflowRunEnqueueCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-run-enqueue.cron.command';
import { WorkflowCronTriggerCronCommand } from 'src/modules/workflow/workflow-trigger/automated-trigger/crons/commands/workflow-cron-trigger.cron.command';
@Command({
name: 'cron:register:all',
description: 'Register all background sync cron jobs',
})
export class CronRegisterAllCommand extends CommandRunner {
private readonly logger = new Logger(CronRegisterAllCommand.name);
constructor(
private readonly messagingMessagesImportCronCommand: MessagingMessagesImportCronCommand,
private readonly messagingMessageListFetchCronCommand: MessagingMessageListFetchCronCommand,
private readonly messagingOngoingStaleCronCommand: MessagingOngoingStaleCronCommand,
private readonly messagingRelaunchFailedMessageChannelsCronCommand: MessagingRelaunchFailedMessageChannelsCronCommand,
private readonly calendarEventListFetchCronCommand: CalendarEventListFetchCronCommand,
private readonly calendarEventsImportCronCommand: CalendarEventsImportCronCommand,
private readonly calendarOngoingStaleCronCommand: CalendarOngoingStaleCronCommand,
private readonly calendarRelaunchFailedCalendarChannelsCronCommand: CalendarRelaunchFailedCalendarChannelsCronCommand,
private readonly webhookSubscriptionRenewalCronCommand: WebhookSubscriptionRenewalCronCommand,
private readonly emailingOngoingStaleCronCommand: EmailingOngoingStaleCronCommand,
private readonly reconcileCampaignStatsCronCommand: ReconcileCampaignStatsCronCommand,
private readonly workflowCronTriggerCronCommand: WorkflowCronTriggerCronCommand,
private readonly workflowRunEnqueueCronCommand: WorkflowRunEnqueueCronCommand,
private readonly workflowHandleStaledRunsCronCommand: WorkflowHandleStaledRunsCronCommand,
private readonly workflowCleanWorkflowRunsCronCommand: WorkflowCleanWorkflowRunsCronCommand,
private readonly workflowCoreConsistencyCronCommand: WorkflowCoreConsistencyCronCommand,
private readonly checkCustomDomainValidRecordsCronCommand: CheckCustomDomainValidRecordsCronCommand,
private readonly checkPublicDomainsValidRecordsCronCommand: CheckPublicDomainsValidRecordsCronCommand,
private readonly checkEmailingDomainVerificationCronCommand: CheckEmailingDomainVerificationCronCommand,
private readonly cronTriggerCronCommand: CronTriggerCronCommand,
private readonly cleanSuspendedWorkspacesCronCommand: CleanSuspendedWorkspacesCronCommand,
private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand,
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
private readonly enterpriseKeyValidationCronCommand: EnterpriseKeyValidationCronCommand,
private readonly rotateSigningKeysCronCommand: RotateSigningKeysCronCommand,
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
private readonly pendingFileCleanupCronCommand: PendingFileCleanupCronCommand,
private readonly billingReminderCronCommand: BillingReminderCronCommand,
private readonly applicationRecurringChargeCronCommand: ApplicationRecurringChargeCronCommand,
private readonly userSessionCleanupCronCommand: UserSessionCleanupCronCommand,
private readonly twentyConfigService: TwentyConfigService,
) {
super();
}
async run(): Promise<void> {
this.logger.log('Registering all background sync cron jobs...');
const isSigningKeyAutoRotationEnabled = isDefined(
this.twentyConfigService.get('SIGNING_KEY_ROTATION_DAYS'),
);
const isMarketplaceCatalogSyncEnabled = this.twentyConfigService.get(
'MARKETPLACE_CATALOG_SYNC_CRON_ENABLED',
);
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
const allCommands = [
{
name: 'MessagingMessagesImport',
command: this.messagingMessagesImportCronCommand,
},
{
name: 'MessagingMessageListFetch',
command: this.messagingMessageListFetchCronCommand,
},
{
name: 'MessagingOngoingStale',
command: this.messagingOngoingStaleCronCommand,
},
{
name: 'MessagingRelaunchFailedMessageChannels',
command: this.messagingRelaunchFailedMessageChannelsCronCommand,
},
{
name: 'CalendarEventListFetch',
command: this.calendarEventListFetchCronCommand,
},
{
name: 'CalendarEventsImport',
command: this.calendarEventsImportCronCommand,
},
{
name: 'CalendarOngoingStale',
command: this.calendarOngoingStaleCronCommand,
},
{
name: 'CalendarRelaunchFailedCalendarChannels',
command: this.calendarRelaunchFailedCalendarChannelsCronCommand,
},
{
name: 'WebhookSubscriptionRenewal',
command: this.webhookSubscriptionRenewalCronCommand,
},
{
name: 'EmailingOngoingStale',
command: this.emailingOngoingStaleCronCommand,
},
{
name: 'ReconcileCampaignStats',
command: this.reconcileCampaignStatsCronCommand,
},
{
name: 'CheckCustomDomainValidRecords',
command: this.checkCustomDomainValidRecordsCronCommand,
},
{
name: 'CheckPublicDomainsValidRecords',
command: this.checkPublicDomainsValidRecordsCronCommand,
},
{
name: 'CheckEmailingDomainVerification',
command: this.checkEmailingDomainVerificationCronCommand,
},
{
name: 'WorkflowCronTrigger',
command: this.workflowCronTriggerCronCommand,
},
{
name: 'WorkflowRunEnqueue',
command: this.workflowRunEnqueueCronCommand,
},
{
name: 'WorkflowHandleStaledRuns',
command: this.workflowHandleStaledRunsCronCommand,
},
{
name: 'WorkflowCleanWorkflowRuns',
command: this.workflowCleanWorkflowRunsCronCommand,
},
{
name: 'WorkflowCoreConsistency',
command: this.workflowCoreConsistencyCronCommand,
},
{
name: 'CronTrigger',
command: this.cronTriggerCronCommand,
},
{
name: 'CleanSuspendedWorkspaces',
command: this.cleanSuspendedWorkspacesCronCommand,
},
{
name: 'CleanOnboardingWorkspaces',
command: this.cleanOnboardingWorkspacesCronCommand,
},
{
name: 'TrashCleanup',
command: this.trashCleanupCronCommand,
},
{
name: 'EventLogCleanup',
command: this.eventLogCleanupCronCommand,
},
{
name: 'MarketplaceCatalogSync',
command: this.marketplaceCatalogSyncCronCommand,
isEnabled: isMarketplaceCatalogSyncEnabled,
},
{
name: 'EnterpriseKeyValidation',
command: this.enterpriseKeyValidationCronCommand,
},
{
name: 'RotateSigningKeys',
command: this.rotateSigningKeysCronCommand,
isEnabled: isSigningKeyAutoRotationEnabled,
},
{
name: 'StaleRegistrationCleanup',
command: this.staleRegistrationCleanupCronCommand,
},
{
name: 'PendingFileCleanup',
command: this.pendingFileCleanupCronCommand,
},
{
name: 'BillingReminder',
command: this.billingReminderCronCommand,
isEnabled: isBillingEnabled,
},
{
name: 'ApplicationRecurringCharge',
command: this.applicationRecurringChargeCronCommand,
isEnabled: isBillingEnabled,
},
{
name: 'UserSessionCleanup',
command: this.userSessionCleanupCronCommand,
},
];
let successCount = 0;
let failureCount = 0;
const failures: string[] = [];
const successes: string[] = [];
const skipped: string[] = [];
for (const { name, command, isEnabled = true } of allCommands) {
if (!isEnabled) {
this.logger.log(`Skipping ${name} cron job (disabled by config)`);
skipped.push(name);
continue;
}
try {
this.logger.log(`Registering ${name} cron job...`);
await command.run();
this.logger.log(`Successfully registered ${name} cron job`);
successCount++;
successes.push(name);
} catch (error) {
this.logger.error(`Failed to register ${name} cron job:`, error);
failureCount++;
failures.push(name);
}
}
this.logger.log(
`Cron job registration completed: ${successCount} successful, ${failureCount} failed, ${skipped.length} skipped`,
);
if (failures.length > 0) {
this.logger.warn(`Failed commands: ${failures.join(', ')}`);
}
if (successCount > 0) {
this.logger.log(`Successful commands: ${successes.join(', ')}`);
}
if (skipped.length > 0) {
this.logger.log(`Skipped commands: ${skipped.join(', ')}`);
}
}
}

View File

@@ -0,0 +1,56 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
SeededWorkspacesIds,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
type DataSeedWorkspaceOptions = {
light?: boolean;
};
@Command({
name: 'workspace:seed:dev',
description:
'Seed workspace with initial data. This command is intended for development only.',
})
export class DataSeedWorkspaceCommand extends CommandRunner {
private readonly logger = new Logger(DataSeedWorkspaceCommand.name);
constructor(private readonly devSeederService: DevSeederService) {
super();
}
@Option({
flags: '--light',
description:
'Light seed: only seed the Apple workspace (skip YCombinator), skip demo custom objects (Pet, Survey, etc.) and limit records to 5 per object',
})
parseLight(): boolean {
return true;
}
async run(
_passedParams: string[],
options: DataSeedWorkspaceOptions,
): Promise<void> {
const workspaceIds: SeededWorkspacesIds[] = options.light
? [SEED_APPLE_WORKSPACE_ID]
: [SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID];
try {
for (const workspaceId of workspaceIds) {
await this.devSeederService.seedDev(workspaceId, {
light: options.light,
});
}
} catch (error) {
this.logger.error(error);
this.logger.error(error.stack);
}
}
}

View File

@@ -0,0 +1,126 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { SecretEncryptionRotationModule } from 'src/database/commands/secret-encryption-rotation/secret-encryption-rotation.module';
import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command';
import { InstallPreInstalledAppsCommand } from 'src/database/commands/install-pre-installed-apps.command';
import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command';
import { RunInstanceCommandsCommand } from 'src/database/commands/run-instance-commands.command';
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { ApplicationRecurringChargeModule } from 'src/engine/core-modules/billing/app-billing/application-recurring-charge.module';
import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/organization-key-validation.cron.command';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module';
import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command';
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { CodeInterpreterSessionCleanupModule } from 'src/engine/core-modules/code-interpreter/crons/code-interpreter-session-cleanup.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-version/workspace-version.module';
import { WebhookSubscriptionModule } from 'src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module';
import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module';
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
import { WorkflowCoreConsistencyModule } from 'src/modules/workflow/workflow-core-consistency/workflow-core-consistency.module';
@Module({
imports: [
UpgradeVersionCommandModule,
TypeOrmModule.forFeature([WorkspaceEntity, RoleEntity]),
WorkspaceExportModule,
MessagingImportManagerModule,
CalendarEventImportManagerModule,
WebhookSubscriptionModule,
EmailingModule,
AutomatedTriggerModule,
WorkflowCoreConsistencyModule,
FileModule,
WorkspaceModule,
WorkflowRunQueueModule,
TypeORMModule,
FieldMetadataModule,
ObjectMetadataModule,
DevSeederModule,
WorkspaceManagerModule,
WorkspaceCacheStorageModule,
ApiKeyModule,
FeatureFlagModule,
WorkspaceCleanerModule,
WorkspaceMigrationModule,
TrashCleanupModule,
BillingReminderModule,
ApplicationRecurringChargeModule,
CodeInterpreterSessionCleanupModule,
PublicDomainModule,
EventLogCleanupModule,
EnterpriseModule,
TwentyConfigModule,
MarketplaceModule,
ApplicationInstallModule,
ApplicationUpgradeModule,
StaleRegistrationCleanupModule,
PreInstalledAppsModule,
WorkspaceIteratorModule,
ApplicationModule,
WorkspaceCacheModule,
WorkspaceVersionModule,
UpgradeModule,
SecretEncryptionRotationModule,
UserSessionModule,
EmailingDomainModule,
],
providers: [
DataSeedWorkspaceCommand,
ConfirmationQuestion,
CronRegisterAllCommand,
GenerateInstanceCommandCommand,
InstanceCommandGenerationService,
RunInstanceCommandsCommand,
ListOrphanedWorkspaceEntitiesCommand,
EnterpriseKeyValidationCronCommand,
RotateSigningKeysCronCommand,
GenerateApiKeyCommand,
UpgradeStatusCommand,
RebuildApplicationDefaultDepsCommand,
InstallPreInstalledAppsCommand,
provideWorkspaceScopedRepository(RoleEntity),
],
})
export class DatabaseCommandModule {}

Some files were not shown because too many files have changed in this diff Show More