Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|
|
|
import chalk from 'chalk';
|
|
import { isNonEmptyString } from '@sniptt/guards';
|
|
import {
|
|
PROVISIONED_WORKSPACE_ACTIVATION_STATUSES,
|
|
WorkspaceActivationStatus,
|
|
} from 'twenty-shared/workspace';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { DataSource, MoreThanOrEqual, Raw, Repository } from 'typeorm';
|
|
|
|
import { CommandShutdownService } from 'src/database/commands/command-runners/command-shutdown.service';
|
|
import { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util';
|
|
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
|
import { WorkspaceOrmManager } from 'src/engine/twenty-orm/workspace-orm.manager';
|
|
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
|
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
|
|
|
|
export type WorkspaceIteratorShard = {
|
|
index: number;
|
|
total: number;
|
|
};
|
|
|
|
export type WorkspaceIteratorArgs = {
|
|
workspaceIds?: string[];
|
|
activationStatuses?: WorkspaceActivationStatus[];
|
|
startFromWorkspaceId?: string;
|
|
workspaceCountLimit?: number;
|
|
shard?: WorkspaceIteratorShard;
|
|
dryRun?: boolean;
|
|
callback: (context: WorkspaceIteratorContext) => Promise<void>;
|
|
};
|
|
|
|
export type WorkspaceIteratorContext = {
|
|
workspaceId: string;
|
|
databaseSchema?: string;
|
|
dataSource?: DataSource;
|
|
index: number;
|
|
total: number;
|
|
};
|
|
|
|
export type WorkspaceIteratorReport = {
|
|
fail: {
|
|
workspaceId: string;
|
|
error: Error;
|
|
}[];
|
|
success: {
|
|
workspaceId: string;
|
|
}[];
|
|
interrupted: boolean;
|
|
};
|
|
|
|
const DEFAULT_ACTIVATION_STATUSES = PROVISIONED_WORKSPACE_ACTIVATION_STATUSES;
|
|
|
|
@Injectable()
|
|
export class WorkspaceIteratorService {
|
|
private readonly logger = new Logger(WorkspaceIteratorService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(WorkspaceEntity)
|
|
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
|
@InjectDataSource()
|
|
private readonly coreDataSource: DataSource,
|
|
private readonly workspaceOrmManager: WorkspaceOrmManager,
|
|
private readonly commandShutdownService: CommandShutdownService,
|
|
) {}
|
|
|
|
listenToShutdownSignals(): void {
|
|
this.commandShutdownService.listenToShutdownSignals();
|
|
}
|
|
|
|
async iterate(args: WorkspaceIteratorArgs): Promise<WorkspaceIteratorReport> {
|
|
const { callback, ...options } = args;
|
|
|
|
const report: WorkspaceIteratorReport = {
|
|
fail: [],
|
|
success: [],
|
|
interrupted: false,
|
|
};
|
|
|
|
const workspaceIdsToProcess =
|
|
options.workspaceIds && options.workspaceIds.length > 0
|
|
? options.workspaceIds
|
|
: await this.fetchWorkspaceIds(options);
|
|
|
|
if (options.dryRun) {
|
|
this.logger.log(chalk.yellow('Dry run mode: No changes will be applied'));
|
|
}
|
|
|
|
for (const [index, workspaceId] of workspaceIdsToProcess.entries()) {
|
|
if (this.commandShutdownService.isShutdownRequested()) {
|
|
this.logger.warn(
|
|
`Shutdown requested, stopping before workspace ${workspaceId}. ` +
|
|
`${workspaceIdsToProcess.length - index} workspace(s) left untouched.`,
|
|
);
|
|
|
|
report.interrupted = true;
|
|
|
|
break;
|
|
}
|
|
|
|
this.logger.log(
|
|
`Running on workspace ${workspaceId} ${index + 1}/${workspaceIdsToProcess.length}`,
|
|
);
|
|
|
|
try {
|
|
const authContext = buildSystemAuthContext(workspaceId);
|
|
|
|
await this.workspaceOrmManager.executeInWorkspaceContext(async () => {
|
|
const workspace = await this.workspaceRepository.findOne({
|
|
select: ['databaseSchema'],
|
|
where: { id: workspaceId },
|
|
});
|
|
|
|
const dataSource = isNonEmptyString(workspace?.databaseSchema)
|
|
? this.coreDataSource
|
|
: undefined;
|
|
|
|
if (!isDefined(dataSource)) {
|
|
this.logger.warn(
|
|
`Could not retrieve a workspace data source for workspace ${workspaceId} ` +
|
|
`(index ${index + 1}/${workspaceIdsToProcess.length}): ` +
|
|
`workspaceRowFound=${isDefined(workspace)}, ` +
|
|
`databaseSchema=${JSON.stringify(workspace?.databaseSchema ?? null)}`,
|
|
);
|
|
}
|
|
|
|
await callback({
|
|
workspaceId,
|
|
databaseSchema: workspace?.databaseSchema ?? undefined,
|
|
dataSource,
|
|
index,
|
|
total: workspaceIdsToProcess.length,
|
|
});
|
|
}, authContext);
|
|
|
|
report.success.push({ workspaceId });
|
|
} catch (error: unknown) {
|
|
report.fail.push({ error: error as Error, workspaceId });
|
|
}
|
|
}
|
|
|
|
report.fail.forEach(({ error, workspaceId }) => {
|
|
this.logger.error(
|
|
`Error in workspace ${workspaceId}: ${error.message}`,
|
|
error.stack,
|
|
);
|
|
|
|
if (error instanceof WorkspaceMigrationRunnerException && error.errors) {
|
|
for (const [label, innerError] of Object.entries(error.errors)) {
|
|
if (!isDefined(innerError)) continue;
|
|
|
|
if (innerError instanceof Error) {
|
|
this.logger.error(
|
|
`Caused by ${label} in workspace ${workspaceId}: ${innerError.message}`,
|
|
innerError.stack,
|
|
);
|
|
} else {
|
|
this.logger.error(
|
|
`Caused by ${label} in workspace ${workspaceId}: ${String(innerError)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return report;
|
|
}
|
|
|
|
private async fetchWorkspaceIds(
|
|
options: Pick<
|
|
WorkspaceIteratorArgs,
|
|
| 'activationStatuses'
|
|
| 'startFromWorkspaceId'
|
|
| 'workspaceCountLimit'
|
|
| 'shard'
|
|
>,
|
|
): Promise<string[]> {
|
|
const activationStatuses =
|
|
options.activationStatuses ?? DEFAULT_ACTIVATION_STATUSES;
|
|
const shard = options.shard;
|
|
|
|
if (isDefined(shard)) {
|
|
if (isDefined(options.startFromWorkspaceId)) {
|
|
throw new Error(
|
|
'Cannot combine shard with startFromWorkspaceId in workspace iterator',
|
|
);
|
|
}
|
|
|
|
if (shard.index < 0 || shard.index >= shard.total || shard.total > 256) {
|
|
throw new Error(
|
|
`Invalid workspace iterator shard ${shard.index}/${shard.total}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const workspaces = await this.workspaceRepository.find({
|
|
select: ['id'],
|
|
where: {
|
|
activationStatus: activationStatusIn(activationStatuses),
|
|
...(options.startFromWorkspaceId
|
|
? { id: MoreThanOrEqual(options.startFromWorkspaceId) }
|
|
: {}),
|
|
...(isDefined(shard)
|
|
? {
|
|
id: Raw(
|
|
(alias) =>
|
|
`mod(get_byte(uuid_send(${alias}), 0), :shardTotal) = :shardIndex`,
|
|
{ shardTotal: shard.total, shardIndex: shard.index },
|
|
),
|
|
}
|
|
: {}),
|
|
},
|
|
order: { id: 'ASC' },
|
|
take: options.workspaceCountLimit,
|
|
});
|
|
|
|
return workspaces.map((workspace) => workspace.id);
|
|
}
|
|
}
|