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:
+2
@@ -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,
|
||||
|
||||
+20
@@ -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'}`,
|
||||
);
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+19
@@ -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 {}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { type I18n } from '@lingui/core';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util';
|
||||
|
||||
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
|
||||
|
||||
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
|
||||
typeof generateMessageId
|
||||
>;
|
||||
|
||||
describe('translateStandardLabel', () => {
|
||||
let mockI18n: jest.Mocked<I18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockI18n = {
|
||||
_: jest.fn(),
|
||||
} as unknown as jest.Mocked<I18n>;
|
||||
});
|
||||
|
||||
it('should return the source value when it is empty', () => {
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: '',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve from the application catalog when provided', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: { 'company-id': 'Entreprise' },
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to the source value when the catalog has no matching entry', () => {
|
||||
mockGenerateMessageId.mockReturnValue('missing-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: {},
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
});
|
||||
|
||||
it('should prefer the catalog over the standard bundle for an application', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('Bundle Translation');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: { 'company-id': 'Entreprise' },
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve from the standard bundle when no catalog is provided', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('Entreprise');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).toHaveBeenCalledWith('company-id');
|
||||
});
|
||||
|
||||
it('should return the source value when the standard bundle has no translation', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('company-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
});
|
||||
|
||||
it('should return the source value for a non-standard app without a catalog', () => {
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
|
||||
export const translateStandardLabel = ({
|
||||
sourceValue,
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
i18nInstance,
|
||||
}: {
|
||||
sourceValue: string;
|
||||
isStandardApp: boolean;
|
||||
applicationCatalog: Record<string, string> | undefined;
|
||||
i18nInstance: I18n;
|
||||
}): string => {
|
||||
if (!isNonEmptyString(sourceValue)) {
|
||||
return sourceValue ?? '';
|
||||
}
|
||||
|
||||
if (!isDefined(applicationCatalog) && !isStandardApp) {
|
||||
return sourceValue;
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(sourceValue);
|
||||
|
||||
if (isDefined(applicationCatalog)) {
|
||||
return applicationCatalog[messageId] ?? sourceValue;
|
||||
}
|
||||
|
||||
if (isStandardApp) {
|
||||
const translatedMessage = i18nInstance._(messageId);
|
||||
|
||||
return translatedMessage === messageId ? sourceValue : translatedMessage;
|
||||
}
|
||||
|
||||
return sourceValue;
|
||||
};
|
||||
Reference in New Issue
Block a user