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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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',
|
||||
|
||||
+7
-5
@@ -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 });
|
||||
}
|
||||
|
||||
+3
-1
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user