Copy application source files during build and install (#22991)

## Summary
This is a requirement for the 2-way sync feature

- Copy logic function and front component source files into the build
output during SDK app builds.
- Share application file list construction between install and build
paths so source and built artifacts stay aligned.
- Allow app installs to skip missing optional source files for backward
compatibility while still requiring built artifacts.
- Extend watcher handling to track source-file uploads and restart when
the source set changes.
- Update integration coverage to assert built outputs and copied source
files for minimal apps.

<img width="940" height="165" alt="Screenshot 2026-07-17 at 14 53 40"
src="https://github.com/user-attachments/assets/b9306990-5fc0-4866-a390-3bb8c21a88ad"
/>
<img width="579" height="181" alt="Screenshot 2026-07-17 at 14 53 57"
src="https://github.com/user-attachments/assets/af282318-76c3-49a9-b62a-833a0aadc688"
/>
This commit is contained in:
Weiko
2026-07-17 17:41:39 +02:00
committed by GitHub
parent 62aa4f6dac
commit 716e67a276
5 changed files with 177 additions and 39 deletions
@@ -94,7 +94,13 @@ describe('minimal-app dev-once', () => {
);
expect(
files.filter((file) => file.includes('.function.')).sort(),
files
.filter(
(file) =>
file.endsWith('.function.mjs') ||
file.endsWith('.function.mjs.map'),
)
.sort(),
).toEqual(['my.function.mjs', 'my.function.mjs.map']);
});
@@ -104,8 +110,26 @@ describe('minimal-app dev-once', () => {
);
expect(
files.filter((file) => file.includes('.front-component.')).sort(),
files
.filter(
(file) =>
file.endsWith('.front-component.mjs') ||
file.endsWith('.front-component.mjs.map'),
)
.sort(),
).toEqual(['my.front-component.mjs', 'my.front-component.mjs.map']);
});
});
describe('source files', () => {
it('should include the logic function source', async () => {
expect(await pathExists(join(OUTPUT_PATH, 'my.function.ts'))).toBe(true);
});
it('should include the front component source', async () => {
expect(
await pathExists(join(OUTPUT_PATH, 'my.front-component.tsx')),
).toBe(true);
});
});
});
@@ -130,6 +130,13 @@ export const buildApplication = async (
onFileBuilt: collectFileBuilt,
});
await copyStaticFiles({
appPath: options.appPath,
fileFolder: FileFolder.Source,
filePaths: [...new Set([...logicFunctions, ...frontComponents])],
collectFileBuilt,
});
await copyStaticFiles({
appPath: options.appPath,
fileFolder: FileFolder.PublicAsset,
@@ -27,6 +27,7 @@ import {
VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE,
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE,
} from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
import { buildApplicationFileList } from 'src/engine/core-modules/application/application-install/utils/build-application-file-list.util';
import { ApplicationManifestApplyService } from 'src/engine/core-modules/application/application-manifest/application-manifest-apply.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
@@ -592,9 +593,9 @@ export class ApplicationInstallService {
applicationUniversalIdentifier: string,
workspaceId: string,
): Promise<void> {
const filesToWrite = this.buildFileList(manifest);
const filesToWrite = buildApplicationFileList(manifest);
for (const { relativePath, fileFolder } of filesToWrite) {
for (const { relativePath, fileFolder, isRequired } of filesToWrite) {
const absolutePath = this.resolveWithinDirOrThrow(
extractedDir,
relativePath,
@@ -604,7 +605,20 @@ export class ApplicationInstallService {
try {
content = await fs.readFile(absolutePath);
} catch {
} catch (error) {
if (
!isRequired &&
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
this.logger.warn(
`Source file not found in package: ${relativePath}; skipping for backward compatibility`,
);
continue;
}
throw new ApplicationException(
`File not found in package: ${relativePath}`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
@@ -677,40 +691,6 @@ export class ApplicationInstallService {
return file.id;
}
private buildFileList(
manifest: Manifest,
): Array<{ relativePath: string; fileFolder: FileFolder }> {
const files: Array<{ relativePath: string; fileFolder: FileFolder }> = [];
files.push(
{ relativePath: 'package.json', fileFolder: FileFolder.Dependencies },
{ relativePath: 'manifest.json', fileFolder: FileFolder.Source },
);
for (const logicFunction of manifest.logicFunctions ?? []) {
files.push({
relativePath: logicFunction.builtHandlerPath,
fileFolder: FileFolder.BuiltLogicFunction,
});
}
for (const frontComponent of manifest.frontComponents ?? []) {
files.push({
relativePath: frontComponent.builtComponentPath,
fileFolder: FileFolder.BuiltFrontComponent,
});
}
for (const publicAsset of manifest.publicAssets ?? []) {
files.push({
relativePath: publicAsset.filePath,
fileFolder: FileFolder.PublicAsset,
});
}
return files;
}
private async ensureApplicationExists(params: {
existingApplication: ApplicationEntity | null;
universalIdentifier: string;
@@ -0,0 +1,62 @@
import { type Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { buildApplicationFileList } from 'src/engine/core-modules/application/application-install/utils/build-application-file-list.util';
describe('buildApplicationFileList', () => {
it('includes source and built files for logic functions and front components', () => {
const manifest = {
logicFunctions: [
{
sourceHandlerPath: 'src/send-email.function.ts',
builtHandlerPath: 'src/send-email.function.mjs',
},
],
frontComponents: [
{
sourceComponentPath: 'src/inbox.front-component.tsx',
builtComponentPath: 'src/inbox.front-component.mjs',
},
],
publicAssets: [{ filePath: 'assets/logo.svg' }],
} as Manifest;
expect(buildApplicationFileList(manifest)).toEqual([
{
relativePath: 'package.json',
fileFolder: FileFolder.Dependencies,
isRequired: true,
},
{
relativePath: 'manifest.json',
fileFolder: FileFolder.Source,
isRequired: true,
},
{
relativePath: 'src/send-email.function.ts',
fileFolder: FileFolder.Source,
isRequired: false,
},
{
relativePath: 'src/send-email.function.mjs',
fileFolder: FileFolder.BuiltLogicFunction,
isRequired: true,
},
{
relativePath: 'src/inbox.front-component.tsx',
fileFolder: FileFolder.Source,
isRequired: false,
},
{
relativePath: 'src/inbox.front-component.mjs',
fileFolder: FileFolder.BuiltFrontComponent,
isRequired: true,
},
{
relativePath: 'assets/logo.svg',
fileFolder: FileFolder.PublicAsset,
isRequired: true,
},
]);
});
});
@@ -0,0 +1,65 @@
import { type Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export type ApplicationFileToStore = {
relativePath: string;
fileFolder: FileFolder;
isRequired: boolean;
};
export const buildApplicationFileList = (
manifest: Manifest,
): ApplicationFileToStore[] => {
const files: ApplicationFileToStore[] = [
{
relativePath: 'package.json',
fileFolder: FileFolder.Dependencies,
isRequired: true,
},
{
relativePath: 'manifest.json',
fileFolder: FileFolder.Source,
isRequired: true,
},
];
for (const logicFunction of manifest.logicFunctions ?? []) {
files.push(
{
relativePath: logicFunction.sourceHandlerPath,
fileFolder: FileFolder.Source,
isRequired: false,
},
{
relativePath: logicFunction.builtHandlerPath,
fileFolder: FileFolder.BuiltLogicFunction,
isRequired: true,
},
);
}
for (const frontComponent of manifest.frontComponents ?? []) {
files.push(
{
relativePath: frontComponent.sourceComponentPath,
fileFolder: FileFolder.Source,
isRequired: false,
},
{
relativePath: frontComponent.builtComponentPath,
fileFolder: FileFolder.BuiltFrontComponent,
isRequired: true,
},
);
}
for (const publicAsset of manifest.publicAssets ?? []) {
files.push({
relativePath: publicAsset.filePath,
fileFolder: FileFolder.PublicAsset,
isRequired: true,
});
}
return files;
};