feat(server): resolve app-owned metadata translations at runtime (#22235)

## Summary

First of a **4-PR stack** that lets apps built with `twenty-sdk`
translate their metadata, resolved at runtime. The standard Twenty app
is modelled as "an app like any other" — `NULL
applicationRegistrationId` ⟺ the standard app, no special-casing.

This PR adds the server foundation and wires runtime resolution for
**object** and **field** metadata:

- New `applicationTranslation` core table + entity (nullable
`applicationRegistrationId`, `locale`, `messages` jsonb), one row per
(app, locale) to avoid multi-MB rows.
- `ApplicationTranslationCacheService` (process-local, 30s TTL) +
`ApplicationTranslationSyncService` (upsert + soft-delete from a
manifest).
- Shared `translateStandardLabel` util: application catalog → i18n
bundle → source value.
- Object/field resolvers + dataloaders prefetch and apply the per-app
catalog. The new `applicationCatalog` param is **optional**, so standard
behaviour is byte-unchanged.
- Fast instance command to create the table.

## Stack
**PR 1/4**, targets `main`. Followed by: (2) twenty-sdk extract/compile
→ `manifest.translations`, (3) resolution across the remaining metadata
resolvers, (4) the per-locale standard-override editor.

## Tests
Unit: `translateStandardLabel`, `resolveObjectMetadataStandardOverride`
(including the application-catalog path).

## Verification note
The remote dev environment for this branch could not complete `yarn
install` (no package-registry egress), so typecheck/lint/tests were not
run locally — **CI is the source of truth** for this stack. Changes
follow existing patterns.

https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22235?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-28 07:36:21 +02:00
committed by GitHub
parent 538b180824
commit 41c10b9ee7
22 changed files with 755 additions and 37 deletions
@@ -5,6 +5,7 @@ import { ApplicationManifestMigrationService } from 'src/engine/core-modules/app
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -18,6 +19,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ApplicationTranslationModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
@@ -8,6 +8,7 @@ import { PackageJson } from 'type-fest';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
@@ -37,6 +38,7 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly applicationTranslationSyncService: ApplicationTranslationSyncService,
@Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN)
private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory,
) {}
@@ -71,6 +73,24 @@ export class ApplicationSyncService {
dryRun,
});
if (!dryRun && isDefined(ownerFlatApplication.applicationRegistrationId)) {
// Translation sync runs after the metadata migration is already applied
// and is non-critical to the application itself, so a failure here must
// never abort an otherwise successful install/sync. It is idempotent and
// self-heals on the next sync.
try {
await this.applicationTranslationSyncService.syncFromManifest({
applicationRegistrationId:
ownerFlatApplication.applicationRegistrationId,
translations: manifest.translations,
});
} catch (error) {
this.logger.warn(
`Failed to sync application translations for registration ${ownerFlatApplication.applicationRegistrationId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.logger.log(
`Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`,
);
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
type ApplicationCatalogsByLocale = Partial<
Record<keyof typeof APP_LOCALES, Record<string, string>>
>;
type ApplicationTranslationCacheEntry = {
catalogsByLocale: ApplicationCatalogsByLocale;
loadedAt: number;
};
const CACHE_TTL_MS = 30_000;
const EMPTY_CATALOG: Record<string, string> = {};
@Injectable()
export class ApplicationTranslationCacheService {
private readonly cache = new Map<string, ApplicationTranslationCacheEntry>();
constructor(
// applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId.
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
@InjectRepository(ApplicationTranslationEntity)
private readonly applicationTranslationRepository: Repository<ApplicationTranslationEntity>,
) {}
async getCatalog({
applicationRegistrationId,
locale,
}: {
applicationRegistrationId: string;
locale: keyof typeof APP_LOCALES;
}): Promise<Record<string, string>> {
const entry = await this.getOrLoadEntry(applicationRegistrationId);
return entry.catalogsByLocale[locale] ?? EMPTY_CATALOG;
}
invalidate(applicationRegistrationId: string): void {
this.cache.delete(applicationRegistrationId);
}
private async getOrLoadEntry(
applicationRegistrationId: string,
): Promise<ApplicationTranslationCacheEntry> {
const cachedEntry = this.cache.get(applicationRegistrationId);
if (
isDefined(cachedEntry) &&
Date.now() - cachedEntry.loadedAt < CACHE_TTL_MS
) {
return cachedEntry;
}
const rows = await this.applicationTranslationRepository.find({
where: { applicationRegistrationId },
});
const catalogsByLocale: ApplicationCatalogsByLocale = {};
for (const row of rows) {
catalogsByLocale[row.locale] = row.messages;
}
const entry: ApplicationTranslationCacheEntry = {
catalogsByLocale,
loadedAt: Date.now(),
};
this.cache.set(applicationRegistrationId, entry);
return entry;
}
}
@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type TranslationsManifest } from 'twenty-shared/application';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
@Injectable()
export class ApplicationTranslationSyncService {
constructor(
// applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId.
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
@InjectRepository(ApplicationTranslationEntity)
private readonly applicationTranslationRepository: Repository<ApplicationTranslationEntity>,
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
) {}
async syncFromManifest({
applicationRegistrationId,
translations,
}: {
applicationRegistrationId: string;
translations: TranslationsManifest | undefined;
}): Promise<void> {
const existingRows = await this.applicationTranslationRepository.find({
where: { applicationRegistrationId },
withDeleted: true,
});
const existingRowByLocale = new Map<
keyof typeof APP_LOCALES,
ApplicationTranslationEntity
>();
for (const row of existingRows) {
const currentRow = existingRowByLocale.get(row.locale);
const shouldPreferRow =
!isDefined(currentRow) ||
(isDefined(currentRow.deletedAt) && !isDefined(row.deletedAt));
if (shouldPreferRow) {
existingRowByLocale.set(row.locale, row);
}
}
const manifestLocales = new Set<keyof typeof APP_LOCALES>();
const upsertPromises: Promise<unknown>[] = [];
for (const [locale, messages] of Object.entries(translations ?? {}) as [
keyof typeof APP_LOCALES,
Record<string, string>,
][]) {
manifestLocales.add(locale);
const existingRow = existingRowByLocale.get(locale);
if (isDefined(existingRow)) {
upsertPromises.push(
this.applicationTranslationRepository.update(existingRow.id, {
messages,
deletedAt: null,
}),
);
} else {
upsertPromises.push(
this.applicationTranslationRepository.insert({
applicationRegistrationId,
locale,
messages,
}),
);
}
}
await Promise.all(upsertPromises);
const rowsToSoftDelete = existingRows.filter(
(row) => !manifestLocales.has(row.locale) && !isDefined(row.deletedAt),
);
if (rowsToSoftDelete.length > 0) {
await this.applicationTranslationRepository.softDelete(
rowsToSoftDelete.map((row) => row.id),
);
}
this.applicationTranslationCacheService.invalidate(
applicationRegistrationId,
);
}
}
@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@Entity({ name: 'applicationTranslation', schema: 'core' })
@Index(
'IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE',
['applicationRegistrationId', 'locale'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
@Index('IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE', ['locale'], {
unique: true,
where: '"deletedAt" IS NULL AND "applicationRegistrationId" IS NULL',
})
export class ApplicationTranslationEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
applicationRegistrationId: string | null;
@ManyToOne(() => ApplicationRegistrationEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity> | null;
@Column({ type: 'text' })
locale: keyof typeof APP_LOCALES;
@Column({ type: 'jsonb', default: {} })
messages: Record<string, string>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service';
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
@Module({
imports: [TypeOrmModule.forFeature([ApplicationTranslationEntity])],
providers: [
ApplicationTranslationCacheService,
ApplicationTranslationSyncService,
],
exports: [
ApplicationTranslationCacheService,
ApplicationTranslationSyncService,
],
})
export class ApplicationTranslationModule {}