Add checksum to manifest (#17368)

## Refactor app-dev state management and build utilities

- Store only `manifest` in `AppDevState` instead of full
`ManifestBuildResult`; add `sourcePath` to `FileStatus`
- Pass `sourcePaths` directly to watchers instead of
`ManifestBuildResult`
- Only reset `fileUploadStatus` for functions/components when their
source paths change
- Add pure `updateManifestChecksum` utility that returns a new manifest
without side effects
- Extract `processEsbuildResult` to deduplicate build result processing
between watchers
- Rename `serverlessFunctions` → `functions` in SDK code (API unchanged)
- Extract `writeManifestToOutput` to shared `manifest-writer.ts`
This commit is contained in:
Charles Bochet
2026-01-22 18:16:55 +01:00
committed by GitHub
parent 843fda7564
commit 210c66b5dd
25 changed files with 459 additions and 155 deletions
@@ -0,0 +1,56 @@
import crypto from 'crypto';
import type * as esbuild from 'esbuild';
import * as fs from 'fs-extra';
import path from 'path';
import { type OnFileBuiltCallback } from './restartable-watcher.interface';
export type ProcessEsbuildResultParams = {
result: esbuild.BuildResult;
outputDir: string;
builtDir: string;
lastChecksums: Map<string, string>;
onFileBuilt?: OnFileBuiltCallback;
onSuccess: (relativePath: string) => void;
};
export type ProcessEsbuildResultOutput = {
hasChanges: boolean;
};
export const processEsbuildResult = async ({
result,
outputDir,
builtDir,
lastChecksums,
onFileBuilt,
onSuccess,
}: ProcessEsbuildResultParams): Promise<ProcessEsbuildResultOutput> => {
const outputFiles = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'));
let hasChanges = false;
for (const outputFile of outputFiles) {
const absoluteOutputFile = path.resolve(outputFile);
const relativePath = path.relative(outputDir, absoluteOutputFile);
const builtPath = `${builtDir}/${relativePath}`;
const content = await fs.readFile(absoluteOutputFile);
const checksum = crypto.createHash('md5').update(content).digest('hex');
const lastChecksum = lastChecksums.get(builtPath);
if (lastChecksum === checksum) {
continue;
}
hasChanges = true;
lastChecksums.set(builtPath, checksum);
onSuccess(relativePath);
if (onFileBuilt) {
onFileBuilt(builtPath, checksum);
}
}
return { hasChanges };
};
@@ -1,14 +1,15 @@
import { type ManifestBuildResult } from '../manifest/manifest-build';
export interface RestartableWatcher {
restart(result: ManifestBuildResult): Promise<void>;
restart(sourcePaths: string[]): Promise<void>;
start(): Promise<void>;
close(): Promise<void>;
shouldRestart(result: ManifestBuildResult): boolean;
shouldRestart(sourcePaths: string[]): boolean;
}
export type OnFileBuiltCallback = (builtPath: string, checksum: string) => void;
export type RestartableWatcherOptions = {
appPath: string;
buildResult: ManifestBuildResult | null;
sourcePaths: string[];
watch?: boolean;
onFileBuilt?: OnFileBuiltCallback;
};
@@ -4,11 +4,12 @@ import path from 'path';
import { cleanupRemovedFiles } from '../common/cleanup-removed-files';
import { OUTPUT_DIR } from '../common/constants';
import { createLogger } from '../common/logger';
import { processEsbuildResult } from '../common/esbuild-result-processor';
import {
type OnFileBuiltCallback,
type RestartableWatcher,
type RestartableWatcherOptions,
} from '../common/restartable-watcher.interface';
import { type ManifestBuildResult } from '../manifest/manifest-build';
import { FRONT_COMPONENTS_DIR } from './constants';
const logger = createLogger('front-components-watch');
@@ -30,17 +31,21 @@ export class FrontComponentsWatcher implements RestartableWatcher {
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
private lastChecksums: Map<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private buildCompletePromise: Promise<void> = Promise.resolve();
private resolveBuildComplete: (() => void) | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.componentPaths = options.buildResult?.filePaths.frontComponents ?? [];
this.componentPaths = options.sourcePaths;
this.watchMode = options.watch ?? true;
this.onFileBuilt = options.onFileBuilt;
}
shouldRestart(result: ManifestBuildResult): boolean {
shouldRestart(sourcePaths: string[]): boolean {
const currentPaths = this.componentPaths.sort().join(',');
const newPaths = result.filePaths.frontComponents.sort().join(',');
const newPaths = [...sourcePaths].sort().join(',');
return currentPaths !== newPaths;
}
@@ -65,7 +70,7 @@ export class FrontComponentsWatcher implements RestartableWatcher {
this.esBuildContext = null;
}
async restart(result: ManifestBuildResult): Promise<void> {
async restart(sourcePaths: string[]): Promise<void> {
if (this.isRestarting) return;
this.isRestarting = true;
@@ -74,9 +79,9 @@ export class FrontComponentsWatcher implements RestartableWatcher {
await this.close();
const outputDir = path.join(this.appPath, OUTPUT_DIR, FRONT_COMPONENTS_DIR);
const newPaths = result.filePaths.frontComponents;
await cleanupRemovedFiles(outputDir, this.componentPaths, newPaths);
this.componentPaths = newPaths;
await cleanupRemovedFiles(outputDir, this.componentPaths, sourcePaths);
this.componentPaths = sourcePaths;
this.lastChecksums.clear();
if (this.componentPaths.length > 0) {
logger.log('🎨 Building...');
@@ -102,8 +107,6 @@ export class FrontComponentsWatcher implements RestartableWatcher {
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
@@ -123,32 +126,30 @@ export class FrontComponentsWatcher implements RestartableWatcher {
{
name: 'build-notifications',
setup: (build) => {
build.onEnd((result) => {
if (result.errors.length > 0) {
logger.error('✗ Build error:');
for (const error of result.errors) {
logger.error(` ${error.text}`);
build.onEnd(async (result) => {
try {
if (result.errors.length > 0) {
logger.error('✗ Build error:');
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
return;
}
return;
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
const { hasChanges } = await processEsbuildResult({
result,
outputDir,
builtDir: FRONT_COMPONENTS_DIR,
lastChecksums: watcher.lastChecksums,
onFileBuilt: watcher.onFileBuilt,
onSuccess: (relativePath) => logger.success(`✓ Built ${relativePath}`),
});
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
if (hasChanges && watchMode) {
logger.log('👀 Watching for changes...');
}
} finally {
watcher.resolveBuildComplete?.();
}
});
},
@@ -156,7 +157,12 @@ export class FrontComponentsWatcher implements RestartableWatcher {
],
});
this.buildCompletePromise = new Promise<void>((resolve) => {
this.resolveBuildComplete = resolve;
});
await this.esBuildContext.rebuild();
await this.buildCompletePromise;
if (this.watchMode) {
await this.esBuildContext.watch();
@@ -4,11 +4,12 @@ import path from 'path';
import { cleanupRemovedFiles } from '../common/cleanup-removed-files';
import { OUTPUT_DIR } from '../common/constants';
import { createLogger } from '../common/logger';
import { processEsbuildResult } from '../common/esbuild-result-processor';
import {
type OnFileBuiltCallback,
type RestartableWatcher,
type RestartableWatcherOptions,
} from '../common/restartable-watcher.interface';
import { type ManifestBuildResult } from '../manifest/manifest-build';
import { FUNCTIONS_DIR } from './constants';
const logger = createLogger('functions-watch');
@@ -44,17 +45,21 @@ export class FunctionsWatcher implements RestartableWatcher {
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private watchMode: boolean;
private lastInputsSignature: string | null = null;
private lastChecksums: Map<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private buildCompletePromise: Promise<void> = Promise.resolve();
private resolveBuildComplete: (() => void) | null = null;
constructor(options: RestartableWatcherOptions) {
this.appPath = options.appPath;
this.functionPaths = options.buildResult?.filePaths.functions ?? [];
this.functionPaths = options.sourcePaths;
this.watchMode = options.watch ?? true;
this.onFileBuilt = options.onFileBuilt;
}
shouldRestart(result: ManifestBuildResult): boolean {
shouldRestart(sourcePaths: string[]): boolean {
const currentPaths = this.functionPaths.sort().join(',');
const newPaths = result.filePaths.functions.sort().join(',');
const newPaths = [...sourcePaths].sort().join(',');
return currentPaths !== newPaths;
}
@@ -79,7 +84,7 @@ export class FunctionsWatcher implements RestartableWatcher {
this.esBuildContext = null;
}
async restart(result: ManifestBuildResult): Promise<void> {
async restart(sourcePaths: string[]): Promise<void> {
if (this.isRestarting) return;
this.isRestarting = true;
@@ -88,9 +93,9 @@ export class FunctionsWatcher implements RestartableWatcher {
await this.close();
const outputDir = path.join(this.appPath, OUTPUT_DIR, FUNCTIONS_DIR);
const newPaths = result.filePaths.functions;
await cleanupRemovedFiles(outputDir, this.functionPaths, newPaths);
this.functionPaths = newPaths;
await cleanupRemovedFiles(outputDir, this.functionPaths, sourcePaths);
this.functionPaths = sourcePaths;
this.lastChecksums.clear();
if (this.functionPaths.length > 0) {
logger.log('📦 Building...');
@@ -116,8 +121,6 @@ export class FunctionsWatcher implements RestartableWatcher {
}
const watchMode = this.watchMode;
// Capture reference for use in plugin callbacks
const watcher = this;
this.esBuildContext = await esbuild.context({
@@ -137,7 +140,6 @@ export class FunctionsWatcher implements RestartableWatcher {
{
name: 'external-patterns',
setup: (build) => {
// Externalize paths containing "generated" (matches /(?:^|\/)generated(?:\/|$)/)
build.onResolve({ filter: /(?:^|\/)generated(?:\/|$)/ }, (args) => ({
path: args.path,
external: true,
@@ -147,32 +149,30 @@ export class FunctionsWatcher implements RestartableWatcher {
{
name: 'build-notifications',
setup: (build) => {
build.onEnd((result) => {
if (result.errors.length > 0) {
logger.error('✗ Build error:');
for (const error of result.errors) {
logger.error(` ${error.text}`);
build.onEnd(async (result) => {
try {
if (result.errors.length > 0) {
logger.error('✗ Build error:');
for (const error of result.errors) {
logger.error(` ${error.text}`);
}
return;
}
return;
}
const inputs = Object.keys(result.metafile?.inputs ?? {}).sort();
const inputsSignature = inputs.join(',');
const { hasChanges } = await processEsbuildResult({
result,
outputDir,
builtDir: FUNCTIONS_DIR,
lastChecksums: watcher.lastChecksums,
onFileBuilt: watcher.onFileBuilt,
onSuccess: (relativePath) => logger.success(`✓ Built ${relativePath}`),
});
if (watcher.lastInputsSignature === inputsSignature) {
return;
}
watcher.lastInputsSignature = inputsSignature;
const outputs = Object.keys(result.metafile?.outputs ?? {})
.filter((file) => file.endsWith('.mjs'))
.map((file) => path.relative(outputDir, file));
for (const output of outputs) {
logger.success(`✓ Built ${output}`);
}
if (watchMode) {
logger.log('👀 Watching for changes...');
if (hasChanges && watchMode) {
logger.log('👀 Watching for changes...');
}
} finally {
watcher.resolveBuildComplete?.();
}
});
},
@@ -180,7 +180,12 @@ export class FunctionsWatcher implements RestartableWatcher {
],
});
this.buildCompletePromise = new Promise<void>((resolve) => {
this.resolveBuildComplete = resolve;
});
await this.esBuildContext.rebuild();
await this.buildCompletePromise;
if (this.watchMode) {
await this.esBuildContext.watch();
@@ -32,7 +32,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [validObjectExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -59,7 +59,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [extensionByUuid],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -86,7 +86,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [validObjectExtension, anotherExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -122,7 +122,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [extensionWithSelect],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -141,7 +141,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -163,7 +163,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -189,7 +189,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -216,7 +216,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -246,7 +246,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -276,7 +276,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -306,7 +306,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -337,7 +337,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -369,7 +369,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -410,7 +410,7 @@ describe('validateManifest - objectExtensions', () => {
application: validApplication,
objects: [],
objectExtensions: [extensionWithDuplicates],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -457,7 +457,7 @@ describe('validateManifest - objectExtensions', () => {
],
},
],
serverlessFunctions: [],
functions: [],
roles: [],
});
@@ -15,7 +15,7 @@ const logger = createLogger('manifest-watch');
type FrontComponentConfig = Omit<
FrontComponentManifest,
'sourceComponentPath' | 'builtComponentPath' | 'componentName'
'sourceComponentPath' | 'builtComponentPath' | 'builtComponentChecksum' | 'componentName'
> & {
component: { name: string };
};
@@ -47,6 +47,7 @@ export class FrontComponentEntityBuilder
componentName: component.name,
sourceComponentPath: filePath,
builtComponentPath,
builtComponentChecksum: null,
});
} catch (error) {
throw new Error(
@@ -15,7 +15,7 @@ const logger = createLogger('manifest-watch');
type ExtractedFunctionManifest = Omit<
ServerlessFunctionManifest,
'sourceHandlerPath' | 'builtHandlerPath'
'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum'
> & {
handlerPath: string;
};
@@ -42,12 +42,15 @@ export class FunctionEntityBuilder
);
const { handlerPath, ...rest } = extracted;
const builtHandlerPath = this.computeBuiltHandlerPath(handlerPath);
// builtHandlerPath is computed from filePath (the .function.ts file)
// since that's what esbuild actually builds, not handlerPath
const builtHandlerPath = this.computeBuiltHandlerPath(filePath);
manifests.push({
...rest,
sourceHandlerPath: handlerPath,
builtHandlerPath,
builtHandlerChecksum: null,
});
} catch (error) {
throw new Error(
@@ -149,7 +152,7 @@ export class FunctionEntityBuilder
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const functions = manifest.serverlessFunctions ?? [];
const functions = manifest.functions ?? [];
for (const fn of functions) {
if (fn.universalIdentifier) {
@@ -2,10 +2,9 @@ import { findPathFile } from '@/cli/utilities/file/utils/file-find';
import { parseJsoncFile } from '@/cli/utilities/file/utils/file-jsonc';
import { glob } from 'fast-glob';
import * as fs from 'fs-extra';
import path, { relative, sep } from 'path';
import { relative, sep } from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type Sources } from 'twenty-shared/types';
import { OUTPUT_DIR } from '../common/constants';
import { createLogger } from '../common/logger';
import { applicationEntityBuilder } from './entities/application';
import { frontComponentEntityBuilder } from './entities/front-component';
@@ -16,6 +15,7 @@ import { roleEntityBuilder } from './entities/role';
import { displayEntitySummary, displayErrors, displayWarnings } from './manifest-display';
import { manifestExtractFromFileServer } from './manifest-extract-from-file-server';
import { validateManifest } from './manifest-validate';
import { writeManifestToOutput } from './manifest-writer';
import { ManifestValidationError } from './manifest.types';
const logger = createLogger('manifest-watch');
@@ -58,25 +58,6 @@ const loadSources = async (appPath: string): Promise<Sources> => {
return sources;
};
const writeManifestToOutput = async (
appPath: string,
manifest: ApplicationManifest,
): Promise<void> => {
try {
const outputDir = path.join(appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
const manifestPath = path.join(outputDir, 'manifest.json');
await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
logger.success(`✓ Written to ${manifestPath}`);
} catch (error) {
logger.error(
`✗ Failed to write: ${error instanceof Error ? error.message : error}`,
);
}
};
export type RunManifestBuildOptions = {
display?: boolean;
writeOutput?: boolean;
@@ -96,6 +77,48 @@ export type ManifestBuildResult = {
filePaths: EntityFilePaths;
};
export type ManifestEntityType = 'function' | 'frontComponent';
export type UpdateManifestChecksumParams = {
manifest: ApplicationManifest;
entityType: ManifestEntityType;
builtPath: string;
checksum: string;
};
export const updateManifestChecksum = ({
manifest,
entityType,
builtPath,
checksum,
}: UpdateManifestChecksumParams): ApplicationManifest | null => {
if (entityType === 'function') {
const fnIndex = manifest.functions.findIndex((f) => f.builtHandlerPath === builtPath);
if (fnIndex === -1) {
return null;
}
return {
...manifest,
functions: manifest.functions.map((fn, index) =>
index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn,
),
};
}
const componentIndex = manifest.frontComponents?.findIndex(
(c) => c.builtComponentPath === builtPath,
) ?? -1;
if (componentIndex === -1) {
return null;
}
return {
...manifest,
frontComponents: manifest.frontComponents?.map((component, index) =>
index === componentIndex ? { ...component, builtComponentChecksum: checksum } : component,
),
};
};
export const runManifestBuild = async (
appPath: string,
options: RunManifestBuildOptions = {},
@@ -152,7 +175,7 @@ export const runManifestBuild = async (
objects: objectManifests,
objectExtensions:
objectExtensionManifests.length > 0 ? objectExtensionManifests : undefined,
serverlessFunctions: functionManifests,
functions: functionManifests,
frontComponents:
frontComponentManifests.length > 0 ? frontComponentManifests : undefined,
roles: roleManifests,
@@ -164,7 +187,7 @@ export const runManifestBuild = async (
application,
objects: objectManifests,
objectExtensions: objectExtensionManifests,
serverlessFunctions: functionManifests,
functions: functionManifests,
frontComponents: frontComponentManifests,
roles: roleManifests,
});
@@ -181,7 +204,8 @@ export const runManifestBuild = async (
}
if (writeOutput) {
await writeManifestToOutput(appPath, manifest);
const manifestPath = await writeManifestToOutput(appPath, manifest);
logger.success(`✓ Written to ${manifestPath}`);
}
return { manifest, filePaths };
@@ -14,7 +14,7 @@ export const displayEntitySummary = (manifest: ApplicationManifest): void => {
manifest.application ? [manifest.application] : [],
);
objectEntityBuilder.display(manifest.objects ?? []);
functionEntityBuilder.display(manifest.serverlessFunctions ?? []);
functionEntityBuilder.display(manifest.functions ?? []);
frontComponentEntityBuilder.display(manifest.frontComponents ?? []);
roleEntityBuilder.display(manifest.roles ?? []);
};
@@ -40,7 +40,7 @@ export const validateManifest = (
);
objectEntityBuilder.validate(manifest.objects ?? [], errors);
objectExtensionEntityBuilder.validate(manifest.objectExtensions ?? [], errors);
functionEntityBuilder.validate(manifest.serverlessFunctions ?? [], errors);
functionEntityBuilder.validate(manifest.functions ?? [], errors);
roleEntityBuilder.validate(manifest.roles ?? [], errors);
frontComponentEntityBuilder.validate(manifest.frontComponents ?? [], errors);
@@ -58,7 +58,7 @@ export const validateManifest = (
});
}
if (!isNonEmptyArray(manifest.serverlessFunctions)) {
if (!isNonEmptyArray(manifest.functions)) {
warnings.push({
message: 'No functions defined',
});
@@ -0,0 +1,18 @@
import * as fs from 'fs-extra';
import path from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { OUTPUT_DIR } from '../common/constants';
export const writeManifestToOutput = async (
appPath: string,
manifest: ApplicationManifest,
): Promise<string> => {
const outputDir = path.join(appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
const manifestPath = path.join(outputDir, 'manifest.json');
await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
return manifestPath;
};