Files
twenty/packages/twenty-sdk/src/cli/operations/dev-once.ts
T
Charles Bochet baf2fc4cc9 Fix sdk-e2e-test: ensure DB is ready before server starts (#19583)
## Summary

- The `sdk-e2e-test` CI job has been failing since at least April 9th
because the nx dependency graph runs `database:reset` and
`start:ci-if-needed` in **parallel** — neither depends on the other. The
server starts before the DB tables are created, crashes with `relation
"core.keyValuePair" does not exist`, and `wait-on` times out after 10
minutes.
- Replace the `nx-affected` orchestration for e2e with explicit
**sequential** CI steps: build → create DB → reset DB → start server →
wait for health → run tests.
- Add server log dump on failure for easier future debugging.

## Root cause

In `packages/twenty-sdk/project.json`, the `test:e2e` target has:

```json
"dependsOn": [
  "build",
  { "target": "database:reset", "projects": "twenty-server" },
  { "target": "start:ci-if-needed", "projects": "twenty-server" }
]
```

Since `database:reset` and `start:ci-if-needed` don't depend on each
other, nx can (and does) run them concurrently. `start:ci-if-needed`
fires `nohup nest start &` and immediately completes. The server process
tries to query `core.keyValuePair` before `database:reset` creates it →
crash → wait-on timeout → job failure.
2026-04-11 16:02:12 +02:00

254 lines
7.0 KiB
TypeScript

import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
import { ApiService } from '@/cli/utilities/api/api-service';
import {
ensureAppAccessTokenIsValidOrRefresh,
ensureAppRegistration,
} from '@/cli/utilities/auth';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppDevOnceOptions = {
appPath: string;
verbose?: boolean;
onProgress?: (message: string) => void;
};
export type AppDevOnceResult = {
outputDir: string;
fileCount: number;
applicationDisplayName: string;
applicationUniversalIdentifier: string;
};
const innerAppDevOnce = async (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> => {
const { appPath, onProgress, verbose } = options;
onProgress?.('Checking server...');
const apiService = new ApiService({ disableInterceptors: true });
const validateAuth = await apiService.validateAuth();
if (!validateAuth.serverUp) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
'Cannot reach Twenty server.\n\n' +
' Start a local server:\n' +
' yarn twenty server start\n\n' +
' Check server status:\n' +
' yarn twenty server status',
},
};
}
if (!validateAuth.authValid) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
'Authentication failed. Run `yarn twenty remote add --local` to authenticate.',
},
};
}
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest: manifestResult.manifest,
filePaths: manifestResult.filePaths,
});
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
const manifest: Manifest = manifestUpdateChecksums({
manifest: manifestResult.manifest,
builtFileInfos: buildResult.builtFileInfos,
});
await writeManifestToOutput(appPath, manifest);
onProgress?.('Registering application...');
const configService = new ConfigService();
const { clientId, clientSecret } = await ensureAppRegistration(
apiService,
configService,
{
name: manifest.application.displayName,
universalIdentifier: manifest.application.universalIdentifier,
},
);
const createDevAppResult = await apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!createDevAppResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to install development application: ${serializeError(createDevAppResult.error)}`,
},
};
}
onProgress?.(
`Uploading ${buildResult.builtFileInfos.size} file${buildResult.builtFileInfos.size === 1 ? '' : 's'}...`,
);
const fileUploader = new FileUploader({
appPath,
applicationUniversalIdentifier: manifest.application.universalIdentifier,
});
const uploadErrors: string[] = [];
const uploadPromises = Array.from(buildResult.builtFileInfos.values()).map(
async (builtFileInfo) => {
if (verbose) {
onProgress?.(`Uploading ${builtFileInfo.builtPath}`);
}
const result = await fileUploader.uploadFile({
builtPath: builtFileInfo.builtPath,
fileFolder: builtFileInfo.fileFolder,
});
if (!result.success) {
uploadErrors.push(
`Failed to upload ${builtFileInfo.builtPath}: ${serializeError(result.error)}`,
);
}
},
);
await Promise.all(uploadPromises);
if (uploadErrors.length > 0) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: uploadErrors.join('\n'),
},
};
}
onProgress?.('Syncing manifest...');
const syncResult = await apiService.syncApplication(manifest);
if (!syncResult.success) {
const errorEvents = verbose
? null
: formatSyncErrorEvents(syncResult.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Sync failed with error: ${serializeError(syncResult.error)}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
},
};
}
onProgress?.('Generating API client...');
try {
const appAccessToken = await ensureAppAccessTokenIsValidOrRefresh(
configService,
{ clientId, clientSecret },
);
const clientService = new ClientService();
await clientService.generateCoreClient({
appPath,
appAccessToken,
});
} catch (error) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to generate API client: ${serializeError(error)}`,
},
};
}
return {
success: true,
data: {
outputDir: path.join(appPath, OUTPUT_DIR),
fileCount: buildResult.builtFileInfos.size,
applicationDisplayName: manifest.application.displayName,
applicationUniversalIdentifier: manifest.application.universalIdentifier,
},
};
};
export const appDevOnce = (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> =>
runSafe(() => innerAppDevOnce(options), APP_ERROR_CODES.SYNC_FAILED);