Fix SDK plan on non installed apps (#22805)

# Context

`yarn twenty plan` fails when the app has never been installed in the
target workspace:

```
Sync failed with error: Application "f5ce204f-..." is not installed in workspace "10f39a9d-...". Install it first.
Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.
```

This forces developers to apply before they can plan, which defeats the
purpose of `plan`. Planning a not-yet-installed app is well defined: the
from-state is empty, so the plan is simply "create everything".

## Why it failed

The dry-run sync required the application row to exist in two places:

1. `ApplicationSyncService.synchronizeFromManifest` threw
`APP_NOT_INSTALLED` when the app row was missing, because the dry-run
needs an owner `FlatApplication` to anchor the from → to metadata diff.
2.
`WorkspaceMigrationFlatEntityMapsService.computeAllInvolvedApplicationIds`
threw when the owner app id was absent from `flatApplicationMaps`, even
though the only hard dependency of a build is the twenty standard
application.

`apply` never hit this because it registers the app row as a side effect
before syncing (and even swallows this exact error on its pre-apply
plan).

# What this PR does

Keeps `plan` strictly read-only, no registration or app row is created:

- **`application-sync.service.ts`**: on dry-run, resolve the owner to
the installed application when it exists (unchanged behavior), otherwise
build a virtual, non-persisted `FlatApplication` from the manifest. Its
freshly generated id matches no existing metadata, so the from-state
slice resolves to empty and every manifest entity shows up as a create.
- **`workspace-migration-flat-entity-maps.service.ts`**: relax the guard
so only the twenty standard application is required. A missing owner app
just contributes an empty from-slice instead of throwing. Installed apps
take the exact same path as before (`applicationId` defined → identical
behavior).
This commit is contained in:
Weiko
2026-07-10 15:11:46 +02:00
committed by GitHub
parent 60f5964c64
commit 666ceb41d5
2 changed files with 54 additions and 16 deletions
@@ -5,7 +5,9 @@ import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { v4 } from 'uuid';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { enrichApplicationManifestSyncError } from 'src/engine/core-modules/application/application-manifest/utils/enrich-application-manifest-sync-error.util';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
@@ -59,7 +61,7 @@ export class ApplicationSyncService {
hasSchemaMetadataChanged: boolean;
}> {
const ownerFlatApplication: FlatApplication = dryRun
? await this.findInstalledApplicationOrThrow({ workspaceId, manifest })
? await this.resolveDryRunOwnerFlatApplication({ workspaceId, manifest })
: await this.syncApplication({
workspaceId,
manifest,
@@ -110,28 +112,62 @@ export class ApplicationSyncService {
return syncResult;
}
private async findInstalledApplicationOrThrow({
private async resolveDryRunOwnerFlatApplication({
workspaceId,
manifest,
}: {
workspaceId: string;
manifest: Manifest;
}): Promise<ApplicationEntity> {
const application = await this.applicationService.findByUniversalIdentifier(
{
}): Promise<FlatApplication> {
const installedApplication =
await this.applicationService.findByUniversalIdentifier({
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
});
return (
installedApplication ??
this.buildVirtualDryRunFlatApplication({ manifest, workspaceId })
);
}
if (!application) {
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`,
ApplicationExceptionCode.APP_NOT_INSTALLED,
);
}
private buildVirtualDryRunFlatApplication({
manifest,
workspaceId,
}: {
manifest: Manifest;
workspaceId: string;
}): FlatApplication {
const now = new Date();
return application;
return {
id: v4(),
workspaceId,
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
description: manifest.application.description ?? null,
logo: manifest.application.logoUrl ?? null,
logoFileId: null,
version: null,
sourceType: ApplicationRegistrationSourceType.LOCAL,
sourcePath: manifest.application.universalIdentifier,
packageJsonChecksum: null,
packageJsonFileId: null,
yarnLockChecksum: null,
yarnLockFileId: null,
availablePackages: {},
logicFunctionLayerId: null,
defaultRoleId: null,
defaultRole: null,
settingsCustomTabFrontComponentId: null,
canBeUninstalled: true,
isSdkLayerStale: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
}
// Registers the application + only the pre-install logic function in
@@ -247,14 +247,16 @@ export class WorkspaceMigrationFlatEntityMapsService {
TWENTY_STANDARD_APPLICATION.universalIdentifier
];
if (!isDefined(twentyStandardApplicationId) || !isDefined(applicationId)) {
if (!isDefined(twentyStandardApplicationId)) {
throw new FlatEntityMapsException(
'Application to build and its dependent application not found',
'Twenty standard application not found in workspace',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
applicationIds.add(applicationId);
if (isDefined(applicationId)) {
applicationIds.add(applicationId);
}
const isBuildingTwentyStandardApplication =
applicationUniversalIdentifier ===