import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; import fs from 'node:fs'; import chalk from 'chalk'; import { createWorkDir, removeWorkDir, createArchive, } from '../util/backup-archive.js'; import { LoggerService } from '../logger/logger.service.js'; import { LOGGER_MODULE_PROVIDER } from '../logger/logger.constants.js'; import { PlatformResolver } from '../platform/platform-resolver.service.js'; import { SqlService } from '../sql/sql.service.js'; import { DirectusLogicalService, SYSTEM_COLLECTIONS, } from '../directus/directus-logical/directus-logical.service.js'; import { DirectusAssetService } from '../directus/directus-asset/directus-asset.service.js'; import { DirectusVersionService } from '../directus/directus-version/directus-version.service.js'; import { DirectusService } from '../directus/directus.service.js'; import { DirectusUserService } from '../directus/directus-user/directus-user.service.js'; import { ConfigService } from '../config/config.service.js'; import { ProgressService } from '../progress/progress.service.js'; import { EnvironmentService } from '../environment/environment.service.js'; @Injectable() export class LogicalBackupPerformer { constructor( @Inject(LOGGER_MODULE_PROVIDER) private readonly logger: LoggerService, private readonly platformResolver: PlatformResolver, private readonly sqlService: SqlService, private readonly directusLogicalService: DirectusLogicalService, private readonly directusAssetService: DirectusAssetService, private readonly directusVersionService: DirectusVersionService, private readonly directusService: DirectusService, private readonly directusUserService: DirectusUserService, private readonly config: ConfigService, private readonly progressService: ProgressService, private readonly environmentService: EnvironmentService, ) {} public async backup( _environmentName: string, backupFile: string, ): Promise { const backupDir = createWorkDir(0o700); const platform = this.platformResolver.resolve( this.environmentService.environment.platform, ); try { this.progressService.advance('🚀 Set-up platform'); const { port, containerService } = await platform.connect(); this.progressService.advance('👤 Set-up Directus user'); await this.sqlService.setupDirectusUser(containerService, port); // The SDK client's `request` is generic over the command type; the // logical service consumes a structural `{ request(cmd: unknown) }`. They // are not bidirectionally assignable, so adapt at the call site. const client = this.directusService.getClient( port, this.directusUserService.token, ) as unknown as { request: (cmd: unknown) => Promise }; this.progressService.advance('📐 Export schema'); const snapshot = await this.directusLogicalService.exportSchema(client); await fs.promises.writeFile( join(backupDir, 'snapshot.json'), JSON.stringify(snapshot, null, 2), ); this.progressService.advance('📤 Export items'); const dataDir = join(backupDir, 'data'); await fs.promises.mkdir(dataDir, { recursive: true }); const userCollections = (snapshot.collections ?? []) // Folder/presentation collections have no table (schema null) and are // not queryable via /items — skip them; only real collections hold data. .filter((c) => (c as { schema?: unknown }).schema != null) .map((c) => c.collection as string) .filter((c) => c && !c.startsWith('directus_')); const collections = [...SYSTEM_COLLECTIONS, ...userCollections]; for (const collection of collections) { const items = await this.directusLogicalService.exportCollection( client, collection, ); await fs.promises.writeFile( join(dataDir, `${collection}.json`), JSON.stringify(items, null, 2), ); } if (this.config.noAssets) { this.logger.debug('Skipping backup of assets'); } else { this.progressService.advance('🖼️ Downloading assets'); await this.directusAssetService.backupAssets( port, backupDir, this.progressService.updateText.bind(this.progressService), ); } this.progressService.advance('🏷️ Save backup metadata'); await this.storeMetadata(port, backupDir); this.progressService.advance('📦 Create backup archive'); const size = await createArchive(backupDir, backupFile); this.progressService.succeed(`Archive is ${chalk.bold(size)} in size`); } catch (error: any) { this.progressService.fail(error); } finally { this.progressService.advance('🛁 Clean-up'); await this.sqlService.cleanUpDirectusUser(); await platform.teardown(); await removeWorkDir(backupDir); this.progressService.finish(); } } private async storeMetadata(port: number, backupDir: string) { const version = await this.directusVersionService.getVersion(port); await fs.promises.writeFile( join(backupDir, 'meta.json'), JSON.stringify( { format: 'logical', version, sourceClient: this.sqlService.client, timestamp: new Date().toISOString(), }, null, 2, ), ); } }