[Upgrade] Fix workspace creation cursor (#19701)
## Summary ### Problem The upgrade migration system required new workspaces to always start from a workspace command, which was too rigid. When the system was mid-upgrade within an instance command (IC) segment, workspace creation would fail or produce inconsistent state. ### Solution #### Workspace-scoped instance command rows Instance commands now write upgrade migration rows for **all active/suspended workspaces** alongside the global row. This means every workspace has a complete migration history, including instance command records. - `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds` immediately before writing records (both success and failure paths) to mitigate race conditions with concurrent workspace creation. - `recordUpgradeMigration` in `UpgradeMigrationService` accepts a discriminated union over `status`, handles `error: unknown` formatting internally, and writes global + workspace rows in batch. #### Flexible initial cursor for new workspaces `getInitialCursorForNewWorkspace` now accepts the last **attempted** (not just completed) instance command with its status: - If the IC is `completed` and the next step is a workspace segment → cursor is set to the last WC of that segment (existing behavior). - If the IC is `failed` or not the last of its segment → cursor is set to that IC itself, preserving its status. This allows workspaces to be created at any point during the upgrade lifecycle, including mid-IC-segment and after IC failure. #### Relaxed workspace segment validation `validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose cursor is: 1. Within the current workspace segment, OR 2. At the immediately preceding instance command with `completed` status (handles the `-w` single-workspace upgrade scenario). Workspaces with cursors in a previous segment, ahead of the current segment, or at a preceding IC with `failed` status are rejected. ### Test plan created empty workspaces to allow testing upgrade with several active workspaces
This commit is contained in:
+32
@@ -21,11 +21,17 @@ export type CreateWorkspaceInput = Pick<
|
||||
export const SEED_APPLE_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
export const SEED_YCOMBINATOR_WORKSPACE_ID =
|
||||
'3b8e6458-5fc1-4e63-8563-008ccddaa6db';
|
||||
export const SEED_EMPTY_WORKSPACE_3_ID = '506915ec-21ca-431b-a04a-257eb216865e';
|
||||
export const SEED_EMPTY_WORKSPACE_4_ID = 'aa8fdcb1-8ee1-4012-98af-44a97caa7411';
|
||||
|
||||
export type SeededWorkspacesIds =
|
||||
| typeof SEED_APPLE_WORKSPACE_ID
|
||||
| typeof SEED_YCOMBINATOR_WORKSPACE_ID;
|
||||
|
||||
export type SeededEmptyWorkspacesIds =
|
||||
| typeof SEED_EMPTY_WORKSPACE_3_ID
|
||||
| typeof SEED_EMPTY_WORKSPACE_4_ID;
|
||||
|
||||
export const SEEDER_CREATE_WORKSPACE_INPUT = {
|
||||
[SEED_APPLE_WORKSPACE_ID]: {
|
||||
id: SEED_APPLE_WORKSPACE_ID,
|
||||
@@ -49,3 +55,29 @@ export const SEEDER_CREATE_WORKSPACE_INPUT = {
|
||||
SeededWorkspacesIds,
|
||||
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
|
||||
>;
|
||||
|
||||
// Empty workspaces with no users, metadata, or data — used by integration tests
|
||||
// that need more than 2 workspaces (e.g. upgrade sequence runner tests).
|
||||
export const SEEDER_CREATE_EMPTY_WORKSPACE_INPUT = {
|
||||
[SEED_EMPTY_WORKSPACE_3_ID]: {
|
||||
id: SEED_EMPTY_WORKSPACE_3_ID,
|
||||
displayName: 'Empty3',
|
||||
subdomain: 'empty3',
|
||||
inviteHash: 'empty3.dev-invite-hash',
|
||||
logo: '',
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
},
|
||||
[SEED_EMPTY_WORKSPACE_4_ID]: {
|
||||
id: SEED_EMPTY_WORKSPACE_4_ID,
|
||||
displayName: 'Empty4',
|
||||
subdomain: 'empty4',
|
||||
inviteHash: 'empty4.dev-invite-hash',
|
||||
logo: '',
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
SeededEmptyWorkspacesIds,
|
||||
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
|
||||
>;
|
||||
|
||||
+23
-7
@@ -10,6 +10,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
|
||||
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
|
||||
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
|
||||
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
@@ -146,6 +147,27 @@ export class DevSeederPermissionsService {
|
||||
roleId: adminRole.id,
|
||||
});
|
||||
|
||||
const memberRole = await this.initMinimalPermissionsAndActivateWorkspace({
|
||||
workspaceId,
|
||||
workspaceCustomFlatApplication,
|
||||
});
|
||||
|
||||
if (memberUserWorkspaceIds.length > 0) {
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: memberUserWorkspaceIds,
|
||||
roleId: memberRole.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async initMinimalPermissionsAndActivateWorkspace({
|
||||
workspaceId,
|
||||
workspaceCustomFlatApplication,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
workspaceCustomFlatApplication: FlatApplication;
|
||||
}): Promise<RoleDTO> {
|
||||
const memberRole = await this.roleService.createMemberRole({
|
||||
workspaceId,
|
||||
ownerFlatApplication: workspaceCustomFlatApplication,
|
||||
@@ -158,13 +180,7 @@ export class DevSeederPermissionsService {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
});
|
||||
|
||||
if (memberUserWorkspaceIds.length > 0) {
|
||||
await this.userRoleService.assignRoleToManyUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceIds: memberUserWorkspaceIds,
|
||||
roleId: memberRole.id,
|
||||
});
|
||||
}
|
||||
return memberRole;
|
||||
}
|
||||
|
||||
private async createLimitedRoleForSeedWorkspace({
|
||||
|
||||
+93
-7
@@ -10,6 +10,7 @@ import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/s
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
@@ -18,7 +19,9 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work
|
||||
import { seedBillingCustomers } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util';
|
||||
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
|
||||
import {
|
||||
type SeededEmptyWorkspacesIds,
|
||||
type SeededWorkspacesIds,
|
||||
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT,
|
||||
SEEDER_CREATE_WORKSPACE_INPUT,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service';
|
||||
@@ -73,14 +76,18 @@ export class DevSeederService {
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
|
||||
|
||||
const lastWorkspaceCommand =
|
||||
this.upgradeSequenceReaderService.getLastWorkspaceCommand();
|
||||
const lastAttemptedInstanceCommand =
|
||||
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
|
||||
const initialCursor =
|
||||
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
|
||||
lastAttemptedInstanceCommand,
|
||||
);
|
||||
|
||||
await this.seedCoreSchema({
|
||||
workspaceId,
|
||||
seedBilling: isBillingEnabled,
|
||||
appVersion,
|
||||
lastUpgradeStepName: lastWorkspaceCommand.name,
|
||||
initialCursor,
|
||||
});
|
||||
|
||||
await this.applicationRegistrationService.createCliRegistrationIfNotExists();
|
||||
@@ -181,15 +188,93 @@ export class DevSeederService {
|
||||
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
|
||||
}
|
||||
|
||||
public async seedEmptyWorkspace(
|
||||
workspaceId: SeededEmptyWorkspacesIds,
|
||||
): Promise<void> {
|
||||
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
|
||||
const lastAttemptedInstanceCommand =
|
||||
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
|
||||
const initialCursor =
|
||||
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
|
||||
lastAttemptedInstanceCommand,
|
||||
);
|
||||
|
||||
const createWorkspaceStaticInput =
|
||||
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT[workspaceId];
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const workspaceCustomApplicationId = v4();
|
||||
|
||||
await createWorkspace({
|
||||
queryRunner,
|
||||
schemaName: 'core',
|
||||
createWorkspaceInput: {
|
||||
...createWorkspaceStaticInput,
|
||||
workspaceCustomApplicationId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.applicationService.createWorkspaceCustomApplication(
|
||||
{
|
||||
workspaceId,
|
||||
applicationId: workspaceCustomApplicationId,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await this.applicationService.createTwentyStandardApplication(
|
||||
{
|
||||
workspaceId,
|
||||
skipCacheInvalidation: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await this.devSeederPermissionsService.initMinimalPermissionsAndActivateWorkspace(
|
||||
{
|
||||
workspaceId,
|
||||
workspaceCustomFlatApplication,
|
||||
},
|
||||
);
|
||||
|
||||
await this.upgradeMigrationService.markAsWorkspaceInitial({
|
||||
name: initialCursor.name,
|
||||
workspaceId,
|
||||
executedByVersion: appVersion,
|
||||
status: initialCursor.status,
|
||||
});
|
||||
|
||||
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
|
||||
}
|
||||
|
||||
private async seedCoreSchema({
|
||||
workspaceId,
|
||||
appVersion,
|
||||
lastUpgradeStepName,
|
||||
initialCursor,
|
||||
seedBilling = true,
|
||||
}: {
|
||||
workspaceId: SeededWorkspacesIds;
|
||||
appVersion: string;
|
||||
lastUpgradeStepName: string;
|
||||
initialCursor: { name: string; status: UpgradeMigrationStatus };
|
||||
seedBilling?: boolean;
|
||||
}): Promise<void> {
|
||||
const schemaName = 'core';
|
||||
@@ -247,10 +332,11 @@ export class DevSeederService {
|
||||
|
||||
await seedMetadataEntities({ queryRunner, schemaName, workspaceId });
|
||||
|
||||
await this.upgradeMigrationService.markAsInitial({
|
||||
name: lastUpgradeStepName,
|
||||
await this.upgradeMigrationService.markAsWorkspaceInitial({
|
||||
name: initialCursor.name,
|
||||
workspaceId,
|
||||
executedByVersion: appVersion,
|
||||
status: initialCursor.status,
|
||||
queryRunner,
|
||||
});
|
||||
|
||||
|
||||
+8
-2
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { In, MoreThanOrEqual, QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -27,11 +27,17 @@ export class WorkspaceVersionService {
|
||||
async getActiveOrSuspendedWorkspaceIds({
|
||||
startFromWorkspaceId,
|
||||
workspaceCountLimit,
|
||||
queryRunner,
|
||||
}: {
|
||||
startFromWorkspaceId?: string;
|
||||
workspaceCountLimit?: number;
|
||||
queryRunner?: QueryRunner;
|
||||
} = {}): Promise<string[]> {
|
||||
const workspaces = await this.workspaceRepository.find({
|
||||
const repository = queryRunner
|
||||
? queryRunner.manager.getRepository(WorkspaceEntity)
|
||||
: this.workspaceRepository;
|
||||
|
||||
const workspaces = await repository.find({
|
||||
select: ['id'],
|
||||
where: {
|
||||
activationStatus: In([
|
||||
|
||||
Reference in New Issue
Block a user