Various SDK improvements (#18115)

## Summary

- **Refactor frontend metadata loading architecture**: Split the
monolithic `EagerMetadataLoadEffect` into focused provider effects
(`UserMetadataProviderEffect`, `ObjectMetadataProviderEffect`,
`ViewMetadataProviderEffect`) orchestrated by `MetadataProviderEffects`.
Replaced `UserProvider` + `ObjectMetadataItemsProvider` with a single
`MetadataGater` that gates rendering on `isAppMetadataReadyState`. The
metadata store now validates view-object consistency before promoting
views, and `updateDraft` skips no-op updates via deep equality checks.

- **SDK CLI improvements**: Added `app:typecheck` command, improved
error handling in API sync (extracts GraphQL error messages), added
`serializeError` utility for human-readable error output, added `error`
file status to dev mode orchestrator with UI support, and fixed
ClickHouse migration/seed commands to use `transpile-only`.
This commit is contained in:
Charles Bochet
2026-02-23 19:57:02 +01:00
committed by GitHub
parent ccddd105d8
commit 0d4fe4575b
38 changed files with 675 additions and 450 deletions
@@ -2,6 +2,7 @@ import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppDevCommand } from './app/app-dev';
import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
import { AuthLoginCommand } from './auth/auth-login';
@@ -60,6 +61,7 @@ export const registerCommands = (program: Command): void => {
// App commands
const devCommand = new AppDevCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
@@ -74,6 +76,15 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('app:typecheck [appPath]')
.description('Run TypeScript type checking on the application')
.action(async (appPath) => {
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:uninstall [appPath]')
.description('Uninstall application from Twenty')
@@ -0,0 +1,43 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import {
runTypecheck,
type TypecheckError,
} from '@/cli/utilities/build/common/typecheck-plugin';
import chalk from 'chalk';
export type AppTypecheckOptions = {
appPath?: string;
};
const formatTypecheckError = (error: TypecheckError): string => {
return `${chalk.cyan(error.file)}:${chalk.yellow(String(error.line))}:${chalk.yellow(String(error.column + 1))} - ${chalk.red('error')} ${error.text}`;
};
export class AppTypecheckCommand {
async execute(options: AppTypecheckOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Running type check...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const errors = await runTypecheck(appPath);
if (errors.length === 0) {
console.log(chalk.green('✓ No type errors found'));
process.exit(0);
}
for (const error of errors) {
console.log(formatTypecheckError(error));
}
console.log('');
console.log(
chalk.red(
`✗ Found ${errors.length} type error${errors.length === 1 ? '' : 's'}`,
),
);
process.exit(1);
}
}
@@ -356,9 +356,27 @@ export class ApiService {
message: `Successfully synced application: ${manifest.application.displayName}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const graphqlErrors = error.response.data?.errors;
if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
return {
success: false,
error: graphqlErrors[0]?.message || error.message,
};
}
return {
success: false,
error:
error.response.data?.message ||
`HTTP ${error.response.status}: ${error.message}`,
};
}
return {
success: false,
error,
error: error instanceof Error ? error.message : error,
};
}
}
@@ -13,7 +13,7 @@ export const getDefaultObjectFields = (
icon: 'Icon123',
isNullable: false,
defaultValue: 'uuid',
type: FieldMetadataType.UUID,
type: FieldMetadataType.UUID as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'id',
@@ -27,7 +27,7 @@ export const getDefaultObjectFields = (
icon: 'IconAbc',
isNullable: true,
defaultValue: null,
type: FieldMetadataType.TEXT,
type: FieldMetadataType.TEXT as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'name',
@@ -41,7 +41,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendar',
isNullable: false,
defaultValue: 'now',
type: FieldMetadataType.DATE_TIME,
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'createdAt',
@@ -55,7 +55,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendarClock',
isNullable: false,
defaultValue: 'now',
type: FieldMetadataType.DATE_TIME,
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'updatedAt',
@@ -69,7 +69,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendarClock',
isNullable: true,
defaultValue: null,
type: FieldMetadataType.DATE_TIME,
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'deletedAt',
@@ -83,7 +83,7 @@ export const getDefaultObjectFields = (
icon: 'IconCreativeCommonsSa',
isNullable: false,
defaultValue: { name: "''", source: "'MANUAL'" },
type: FieldMetadataType.ACTOR,
type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'createdBy',
@@ -97,7 +97,7 @@ export const getDefaultObjectFields = (
icon: 'IconUserCircle',
isNullable: false,
defaultValue: { name: "''", source: "'MANUAL'" },
type: FieldMetadataType.ACTOR,
type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'updatedBy',
@@ -40,7 +40,8 @@ export type OrchestratorStateFileStatus =
| 'pending'
| 'building'
| 'uploading'
| 'success';
| 'success'
| 'error';
export type OrchestratorStateEntityInfo = {
name: string;
@@ -81,10 +82,11 @@ const FILE_STATUS_TRANSITION_MATRIX: Record<
OrchestratorStateFileStatus,
OrchestratorStateFileStatus[]
> = {
pending: ['building', 'uploading', 'success'],
building: ['pending', 'uploading', 'success'],
uploading: ['pending', 'success'],
success: ['pending', 'building', 'uploading'],
pending: ['building', 'uploading', 'success', 'error'],
building: ['pending', 'uploading', 'success', 'error'],
uploading: ['pending', 'success', 'error'],
success: ['pending', 'building', 'uploading', 'error'],
error: ['pending', 'building', 'uploading', 'success'],
};
export class OrchestratorState {
@@ -13,6 +13,7 @@ import {
} from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import * as fs from 'fs-extra';
import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
@@ -143,10 +144,11 @@ export class DevModeOrchestrator {
await this.runSyncPipeline();
} catch (error) {
this.state.addEvent({
message: `Sync failed with error ${JSON.stringify(error, null, 2)}`,
message: `Sync failed with error: ${serializeError(error)}`,
status: 'error',
});
this.state.updatePipeline({ status: 'error' });
this.state.updateAllEntitiesStatus('error');
} finally {
this.state.updatePipeline({ isSyncing: false });
}
@@ -7,6 +7,7 @@ import {
type OrchestratorStateStepEvent,
type OrchestratorStateSyncStatus,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
export type SyncApplicationOrchestratorStepOutput = {
@@ -73,12 +74,13 @@ export class SyncApplicationOrchestratorStep {
return;
}
const errorMessage = `Sync failed with error ${JSON.stringify(syncResult.error, null, 2)}`;
const errorMessage = `Sync failed with error: ${serializeError(syncResult.error)}`;
events.push({ message: errorMessage, status: 'error' });
step.output = { syncStatus: 'error', error: errorMessage };
step.status = 'error';
this.state.updatePipeline({ status: 'error', error: errorMessage });
this.state.updateAllEntitiesStatus('error');
this.state.applyStepEvents(events);
}
}
@@ -92,7 +92,11 @@ export const DevUiEntityLegend = (): React.ReactElement => {
<Text color={DEV_UI_STATUS_CONFIG.done.color}>
{DEV_UI_STATUS_CONFIG.done.icon}
</Text>{' '}
success
success{' '}
<Text color={DEV_UI_STATUS_CONFIG.error.color}>
{DEV_UI_STATUS_CONFIG.error.icon}
</Text>{' '}
error
</Text>
</Box>
);
@@ -49,6 +49,7 @@ export const mapFileStatusToDevUiStatus = (
building: 'in_progress',
uploading: 'uploading',
success: 'done',
error: 'error',
};
return mapping[status];
@@ -182,6 +183,7 @@ export const getPipelineRows = (
): DevUiPipelineRow[] => {
const entities = [...state.entities.values()];
const hasError = entities.some((entity) => entity.status === 'error');
const isBuilding = entities.some((entity) => entity.status === 'building');
const allUploaded =
entities.length > 0 &&
@@ -189,11 +191,13 @@ export const getPipelineRows = (
(entity) => entity.status === 'uploading' || entity.status === 'success',
);
const resourcesBuildStatus: OrchestratorStateStepStatus = isBuilding
? 'in_progress'
: allUploaded
? 'done'
: 'idle';
const resourcesBuildStatus: OrchestratorStateStepStatus = hasError
? 'error'
: isBuilding
? 'in_progress'
: allUploaded
? 'done'
: 'idle';
return [
{
@@ -0,0 +1,52 @@
import axios from 'axios';
export const serializeError = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
if (axios.isAxiosError(error)) {
const parts: string[] = [];
const status = error.response?.status;
const statusText = error.response?.statusText;
if (status) {
parts.push(`HTTP ${status}${statusText ? ` ${statusText}` : ''}`);
}
const graphqlErrors = error.response?.data?.errors;
if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
const messages = graphqlErrors
.map(
(graphqlError: { message?: string }) =>
graphqlError.message ?? 'Unknown GraphQL error',
)
.join('; ');
parts.push(messages);
} else if (error.response?.data?.message) {
parts.push(error.response.data.message);
} else if (error.message) {
parts.push(error.message);
}
if (error.code) {
parts.push(`(${error.code})`);
}
return parts.join(' - ') || 'Unknown Axios error';
}
if (error instanceof Error) {
return error.message || error.toString();
}
const stringified = JSON.stringify(error, null, 2);
if (stringified === '{}' || stringified === undefined) {
return String(error);
}
return stringified;
};