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 {}
|
||||
Reference in New Issue
Block a user