Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 0c5caaa9e6 chore: additional changes for Missing messageChannel entity metadata during nest 2026-03-13 16:46:53 +00:00
Sonarly Claude Code c5928610da chore: improve monitoring for Missing messageChannel entity metadata during nest
**Monitoring: Prevent secondary errors from masking original errors in message import error handler**

The `handleUnknownException` method in `MessageImportExceptionHandlerService` captures the original exception to Sentry, then calls `markAsFailed()`. If `markAsFailed` throws (as in this bug), the secondary error propagates up and becomes the Sentry-reported error — masking the original cause. The Sentry event we investigated only showed the `EntityMetadataNotFoundError`, not the original message import failure.

The fix:
1. Added a `Logger` instance to the service
2. Wrapped `markAsFailed` in a try-catch that logs BOTH the original error and the secondary error
3. The secondary error is captured separately to Sentry with workspace context

This ensures:
- The original error is always captured (already was)
- If `markAsFailed` fails, the secondary error is captured with context linking it to the original
- The log line includes both errors for correlation
- The secondary error no longer masks or replaces the original error

**File:** `packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts`
2026-03-13 16:46:50 +00:00
Sonarly Claude Code af040b48a6 Missing messageChannel entity metadata during nested workspace context in error handler
https://sonarly.com/issue/14490?type=bug

Worker fails to mark a message channel as "failed" because the ORM entity metadata cache for the workspace doesn't include the `messageChannel` entity when loaded in a nested `executeInWorkspaceContext` call during error handling.

Fix: **Fix: Align `findMetadata` with TypeORM's string target resolution**

The `GlobalWorkspaceDataSource.findMetadata()` override used only strict reference equality (`metadata.target === target`) to match entity metadata. TypeORM's built-in `DataSource.getMetadata()` has additional fallback logic for string targets — it also checks `metadata.tableName` and `metadata.name`. The override was missing these fallbacks.

When `target` is a string like `'messageChannel'` (as passed from `GlobalWorkspaceOrmManager.getRepository(workspaceId, 'messageChannel')`), and the cached `EntityMetadata.target` is set to the EntitySchema instance (not the string name), the strict `===` comparison fails. The fix adds the same fallback logic TypeORM uses: for string targets, also match against `metadata.tableName` and `metadata.name`.

The existing `===` check is preserved as the first (fast-path) condition, so no existing behavior is broken. The string fallback only activates when the strict check fails.

**File:** `packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.ts`
2026-03-13 16:46:49 +00:00
6 changed files with 101 additions and 77 deletions
@@ -1263,95 +1263,95 @@ The build process:
1. **Parses and validates the manifest** — reads all `defineX()` entities from your source files and validates the manifest structure.
2. **Compiles logic functions and front components** — bundles TypeScript sources into ESM `.mjs` files using esbuild.
3. **Generates checksums** — computes MD5 hashes for each built file, stored in the manifest as `builtHandlerChecksum` / `builtComponentChecksum`.
4. **Generează clientul API tipizat** — examinează schema GraphQL și generează clienți tipizați `CoreApiClient` și `MetadataApiClient`.
5. **Rulează o verificare a tipurilor TypeScript** — rulează `tsc --noEmit` pentru a detecta erorile de tip înainte de publicare.
6. **Reconstruiește cu clientul generat** — efectuează o a doua trecere de compilare astfel încât tipurile clientului generat să fie incluse.
7. **Creează opțional un tarball** — dacă se trece `--tarball`, rulează `npm pack` pentru a crea un fișier `.tgz` gata pentru distribuire.
4. **Generates the typed API client** — introspects the GraphQL schema and generates typed `CoreApiClient` and `MetadataApiClient` clients.
5. **Runs a TypeScript type check** — runs `tsc --noEmit` to catch type errors before publishing.
6. **Rebuilds with the generated client** — performs a second compilation pass so the generated client types are included.
7. **Optionally creates a tarball** — if `--tarball` is passed, runs `npm pack` to create a `.tgz` file ready for distribution.
Rezultatul build-ului din `.twenty/output/` conține:
The build output in `.twenty/output/` contains:
```text
.twenty/output/
├── manifest.json # Manifest cu sume de control pentru toate fișierele generate
├── package.json # Copiat din rădăcina aplicației
├── yarn.lock # Copiat din rădăcina aplicației
├── manifest.json # Manifest with checksums for all built files
├── package.json # Copied from app root
├── yarn.lock # Copied from app root
├── src/
│ ├── logic-functions/ # Fișiere .mjs de funcții de logică compilate
│ └── front-components/ # Fișiere .mjs de componente front-end compilate
├── public/ # Resurse statice (dacă există)
└── my-app-1.0.0.tgz # Doar cu opțiunea --tarball
│ ├── logic-functions/ # Compiled .mjs logic function files
│ └── front-components/ # Compiled .mjs front component files
├── public/ # Static assets (if any)
└── my-app-1.0.0.tgz # Only with --tarball flag
```
| Opțiune | Descriere |
| ----------- | -------------------------------------------------------------- |
| `[appPath]` | Calea către directorul aplicației (implicit directorul curent) |
| `--tarball` | De asemenea, împachetează rezultatul într-un tarball `.tgz` |
| Opțiune | Descriere |
| ----------- | --------------------------------------------------------- |
| `[appPath]` | Path to the app directory (defaults to current directory) |
| `--tarball` | Also pack the output into a `.tgz` tarball |
## Publicarea aplicației
## Publishing your app
Folosește `app:publish` pentru a distribui aplicația — fie în registrul npm, fie direct pe un server Twenty.
Use `app:publish` to distribute your app — either to the npm registry or directly to a Twenty server.
### Publicare pe npm (implicit)
### Publish to npm (default)
```bash filename="Terminal"
# Publicare pe npm (necesită autentificare npm)
# Publish to npm (requires npm login)
yarn twenty app:publish
# Publicare cu un dist-tag (de ex. beta, next)
# Publish with a dist-tag (e.g. beta, next)
yarn twenty app:publish --tag beta
```
Aceasta construiește aplicația și rulează `npm publish` din directorul `.twenty/output/`. Pachetul publicat poate fi apoi instalat din marketplace-ul Twenty de către orice spațiu de lucru.
This builds the app and runs `npm publish` from the `.twenty/output/` directory. The published package can then be installed from the Twenty marketplace by any workspace.
### Publicare pe un server Twenty
### Publish to a Twenty server
```bash filename="Terminal"
# Publicare direct pe un server Twenty
# Publish directly to a Twenty server
yarn twenty app:publish --server https://app.twenty.com
```
Aceasta construiește aplicația cu un tarball, o încarcă pe server prin mutația GraphQL `uploadAppTarball` și declanșează instalarea într-un singur pas. Acest lucru este util pentru implementări private sau pentru testare pe un server specific.
This builds the app with a tarball, uploads it to the server via the `uploadAppTarball` GraphQL mutation, and triggers installation in one step. This is useful for private deployments or testing against a specific server.
| Opțiune | Descriere |
| ----------------- | -------------------------------------------------------------------- |
| `[appPath]` | Calea către directorul aplicației (implicit directorul curent) |
| `--server <url>` | Publică pe un server Twenty în loc de npm |
| `--token <token>` | Jeton de autentificare pentru serverul țintă |
| `--tag <tag>` | npm dist-tag (de ex. `beta`, `next`) — doar pentru publicarea pe npm |
| Opțiune | Descriere |
| ----------------- | --------------------------------------------------------- |
| `[appPath]` | Path to the app directory (defaults to current directory) |
| `--server <url>` | Publish to a Twenty server instead of npm |
| `--token <token>` | Authentication token for the target server |
| `--tag <tag>` | npm dist-tag (e.g. `beta`, `next`) — only for npm publish |
## Înregistrarea aplicației
## Application registration
Înainte ca o aplicație să poată fi instalată într-un spațiu de lucru, aceasta trebuie să fie **înregistrată**. O înregistrare este o înregistrare de metadate care descrie de unde provine aplicația și cum se autentifică. Acest lucru este gestionat automat de CLI în cele mai multe cazuri.
Before an app can be installed in a workspace, it must be **registered**. A registration is a metadata record that describes where the app comes from and how to authenticate it. This is handled automatically by the CLI in most cases.
### Tipuri de sursă
### Source types
Fiecare înregistrare are un **tip de sursă** care determină modul în care fișierele aplicației sunt preluate în timpul instalării:
Each registration has a **source type** that determines how the app's files are resolved during installation:
| Tip de sursă | Cum sunt preluate fișierele | Caz de utilizare tipic |
| ------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------- |
| `LOCAL` | Fișierele sunt sincronizate în timp real de către watcher-ul CLI — instalarea este omisă | Dezvoltare cu `app:dev` |
| `NPM` | Obținute din registrul npm prin câmpul `sourcePackage` | Aplicații publicate pe npm |
| `TARBALL` | Extrase dintr-un fișier `.tgz` încărcat, stocat pe server | Aplicații private publicate cu `--server` |
| Source type | How files are resolved | Typical use case |
| ----------- | -------------------------------------------------------------------------- | -------------------------------------- |
| `LOCAL` | Files are synced in real-time by the CLI watcher — installation is skipped | Development with `app:dev` |
| `NPM` | Fetched from the npm registry via the `sourcePackage` field | Published apps on npm |
| `TARBALL` | Extracted from an uploaded `.tgz` file stored on the server | Private apps published with `--server` |
### Cum are loc înregistrarea
### How registration happens
* **`app:dev`** — creează automat o înregistrare `LOCAL` prima dată când rulezi modul de dezvoltare pentru un spațiu de lucru.
* **`app:publish --server`** — încarcă un tarball și creează (sau actualizează) o înregistrare `TARBALL`, apoi instalează aplicația.
* **marketplace-ul npm** — înregistrările `NPM` sunt create când aplicațiile sunt sincronizate din registrul npm în catalogul marketplace-ului Twenty.
* **API GraphQL** — poți de asemenea să creezi înregistrări programatic prin mutația `createApplicationRegistration`.
* **`app:dev`** — automatically creates a `LOCAL` registration the first time you run dev mode against a workspace.
* **`app:publish --server`** — uploads a tarball and creates (or updates) a `TARBALL` registration, then installs the app.
* **npm marketplace** — `NPM` registrations are created when apps are synced from the npm registry into the Twenty marketplace catalog.
* **GraphQL API** — you can also create registrations programmatically via the `createApplicationRegistration` mutation.
### Înregistrare vs instalare
### Registration vs installation
**Înregistrarea** și **instalarea** sunt concepte separate:
**Registration** and **installation** are separate concepts:
* O **înregistrare** (`ApplicationRegistration`) este o înregistrare globală de metadate care descrie aplicația: numele ei, tipul de sursă, acreditările OAuth și statutul listării în marketplace. Există independent de orice spațiu de lucru.
* O **instalare** (`Application`) este o instanță per spațiu de lucru. Când un utilizator instalează o aplicație, Twenty rezolvă pachetul din sursa înregistrării, scrie fișierele compilate în stocare și sincronizează manifestul (creând obiecte, câmpuri, funcții de logică etc.) în acel spațiu de lucru.
* A **registration** (`ApplicationRegistration`) is a global metadata record describing the app: its name, source type, OAuth credentials, and marketplace listing status. It exists independently of any workspace.
* An **installation** (`Application`) is a per-workspace instance. When a user installs an app, Twenty resolves the package from the registration's source, writes the built files to storage, and synchronizes the manifest (creating objects, fields, logic functions, etc.) in that workspace.
O singură înregistrare poate fi instalată în multe spații de lucru. Fiecare spațiu de lucru primește propria copie a fișierelor și a modelului de date al aplicației.
One registration can be installed in many workspaces. Each workspace gets its own copy of the app's files and data model.
### Acreditări OAuth
### OAuth credentials
Fiecare înregistrare include acreditări OAuth (`oAuthClientId` și `oAuthClientSecret`) generate la momentul creării. Acestea sunt folosite de aplicație pentru a autentifica cererile API în numele utilizatorilor. Secretul clientului este returnat **o singură dată** la crearestrează-l în siguranță. Îl poți roti ulterior prin mutația `rotateApplicationRegistrationClientSecret`.
Each registration includes OAuth credentials (`oAuthClientId` and `oAuthClientSecret`) generated at creation time. These are used by the app to authenticate API requests on behalf of users. The client secret is returned **once** at creation — store it securely. You can rotate it later via the `rotateApplicationRegistrationClientSecret` mutation.
## Configurare manuală (fără generator)
@@ -1246,9 +1246,9 @@ uploadFile(
Nesneleri, mantık fonksiyonlarını, ön uç bileşenlerini ve birden çok tetikleyiciyi gösteren minimal, uçtan uca bir örneği [buradan](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world) inceleyin:
## Uygulamanızı derleme
## Building your app
Uygulamanızı `app:dev` ile geliştirdikten sonra, `app:build` kullanarak onu dağıtılabilir bir pakete derleyin.
Once you've developed your app with `app:dev`, use `app:build` to compile it into a distributable package.
```bash filename="Terminal"
# Build the app (output goes to .twenty/output/)
@@ -1258,11 +1258,11 @@ yarn twenty app:build
yarn twenty app:build --tarball
```
Derleme süreci:
The build process:
1. **Manifesti ayrıştırır ve doğrular** — kaynak dosyalarınızdaki tüm `defineX()` varlıklarını okur ve manifest yapısını doğrular.
2. **Mantık işlevlerini ve ön bileşenleri derler** — TypeScript kaynaklarını esbuild kullanarak ESM `.mjs` dosyalarına paketler.
3. **Sağlama toplamları üretir** — her bir oluşturulan dosya için MD5 karmalarını hesaplar ve manifestte `builtHandlerChecksum` / `builtComponentChecksum` olarak saklar.
1. **Parses and validates the manifest** — reads all `defineX()` entities from your source files and validates the manifest structure.
2. **Compiles logic functions and front components** — bundles TypeScript sources into ESM `.mjs` files using esbuild.
3. **Generates checksums** — computes MD5 hashes for each built file, stored in the manifest as `builtHandlerChecksum` / `builtComponentChecksum`.
4. **Generates the typed API client** — introspects the GraphQL schema and generates typed `CoreApiClient` and `MetadataApiClient` clients.
5. **Runs a TypeScript type check** — runs `tsc --noEmit` to catch type errors before publishing.
6. **Rebuilds with the generated client** — performs a second compilation pass so the generated client types are included.
@@ -85,7 +85,17 @@ export class GlobalWorkspaceDataSource extends DataSource {
const context = getWorkspaceContext();
const { entityMetadatas } = context;
return entityMetadatas.find((metadata) => metadata.target === target);
return entityMetadatas.find((metadata) => {
if (metadata.target === target) {
return true;
}
if (typeof target === 'string') {
return metadata.tableName === target || metadata.name === target;
}
return false;
});
}
override getMetadata(target: EntityTarget<ObjectLiteral>): EntityMetadata {
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
@@ -29,6 +29,10 @@ export enum MessageImportSyncStep {
@Injectable()
export class MessageImportExceptionHandlerService {
private readonly logger = new Logger(
MessageImportExceptionHandlerService.name,
);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelSyncStatusService: MessageChannelSyncStatusService,
@@ -226,11 +230,21 @@ export class MessageImportExceptionHandlerService {
this.exceptionHandlerService.captureExceptions([exception], {
workspace: { id: workspaceId },
});
await this.messageChannelSyncStatusService.markAsFailed(
[messageChannel.id],
workspaceId,
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
try {
await this.messageChannelSyncStatusService.markAsFailed(
[messageChannel.id],
workspaceId,
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
} catch (markAsFailedError) {
this.logger.error(
`Failed to mark message channel ${messageChannel.id} as failed in workspace ${workspaceId}: ${markAsFailedError.message}. Original error: ${exception.message}`,
);
this.exceptionHandlerService.captureExceptions([markAsFailedError], {
workspace: { id: workspaceId },
});
}
}
private async handlePermanentException(
@@ -27,9 +27,9 @@ export const Default: Story = {
variant: 'primary',
accent: 'danger',
children: [
<Button key="note" Icon={IconNotes} title="Note" />,
<Button key="task" Icon={IconCheckbox} title="Task" />,
<Button key="activity" Icon={IconTimelineEvent} title="Activity" />,
<Button Icon={IconNotes} title="Note" />,
<Button Icon={IconCheckbox} title="Task" />,
<Button Icon={IconTimelineEvent} title="Activity" />,
],
},
argTypes: {
@@ -41,9 +41,9 @@ export const Default: Story = {
export const Catalog: CatalogStory<Story, typeof ButtonGroup> = {
args: {
children: [
<Button key="note" Icon={IconNotes} title="Note" />,
<Button key="task" Icon={IconCheckbox} title="Task" />,
<Button key="activity" Icon={IconTimelineEvent} title="Activity" />,
<Button Icon={IconNotes} title="Note" />,
<Button Icon={IconCheckbox} title="Task" />,
<Button Icon={IconTimelineEvent} title="Activity" />,
],
},
argTypes: {
@@ -20,9 +20,9 @@ export const Default: Story = {
args: {
size: 'small',
children: [
<FloatingButton key="notes" Icon={IconNotes} />,
<FloatingButton key="checkbox" Icon={IconCheckbox} />,
<FloatingButton key="timeline" Icon={IconTimelineEvent} />,
<FloatingButton Icon={IconNotes} />,
<FloatingButton Icon={IconCheckbox} />,
<FloatingButton Icon={IconTimelineEvent} />,
],
},
argTypes: {
@@ -34,9 +34,9 @@ export const Default: Story = {
export const Catalog: CatalogStory<Story, typeof FloatingButtonGroup> = {
args: {
children: [
<FloatingButton key="notes" Icon={IconNotes} />,
<FloatingButton key="checkbox" Icon={IconCheckbox} />,
<FloatingButton key="timeline" Icon={IconTimelineEvent} />,
<FloatingButton Icon={IconNotes} />,
<FloatingButton Icon={IconCheckbox} />,
<FloatingButton Icon={IconTimelineEvent} />,
],
},
argTypes: {