createVersion($workspace, 'Before CDN migration'); * * // Make changes... * $config->set('cdn.provider', 'bunny', $profile); * * // Rollback if needed * $versioning->rollback($version->id, $workspace); * * // Compare versions * $diff = $versioning->compare($workspace, $oldVersionId, $newVersionId); * ``` * * ## Version Structure * * Each version stores: * - Scope (workspace/system) * - Timestamp * - Label/description * - Full snapshot of all config values * - Author (if available) * * @see ConfigService For runtime config access * @see ConfigExporter For import/export operations */ class ConfigVersioning { /** * Maximum versions to keep per scope (configurable). */ protected int $maxVersions; public function __construct( protected ConfigService $config, protected ConfigExporter $exporter, ) { $this->maxVersions = (int) config('core.config.max_versions', 50); } /** * Create a new config version (snapshot). * * @param object|null $workspace Workspace model instance or null for system scope * @param string $label Version label/description * @param string|null $author Author identifier (user ID, email, etc.) * @return ConfigVersion The created version */ public function createVersion( ?object $workspace = null, string $label = '', ?string $author = null, ): ConfigVersion { $profile = $this->getOrCreateProfile($workspace); // Get current config as JSON snapshot $snapshot = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); $version = ConfigVersion::create([ 'profile_id' => $profile->id, 'workspace_id' => $workspace?->id, 'label' => $label ?: 'Version '.now()->format('Y-m-d H:i:s'), 'snapshot' => $snapshot, 'author' => $author ?? $this->getCurrentAuthor(), 'created_at' => now(), ]); // Enforce retention policy $this->pruneOldVersions($profile->id); return $version; } /** * Rollback to a specific version. * * @param int $versionId Version ID to rollback to * @param object|null $workspace Workspace model instance or null for system scope * @param bool $createBackup Create a backup version before rollback (default: true) * @return ImportResult Import result with stats * * @throws \InvalidArgumentException If version not found or scope mismatch */ public function rollback( int $versionId, ?object $workspace = null, bool $createBackup = true, ): ImportResult { $version = ConfigVersion::find($versionId); if ($version === null) { throw new \InvalidArgumentException("Version not found: {$versionId}"); } // Verify scope matches $workspaceId = $workspace?->id; if ($version->workspace_id !== $workspaceId) { throw new \InvalidArgumentException('Version scope does not match target scope'); } // Create backup before rollback if ($createBackup) { $this->createVersion($workspace, 'Backup before rollback to version '.$versionId); } // Import the snapshot return $this->exporter->importJson($version->snapshot, $workspace); } /** * Get all versions for a scope. * * @param object|null $workspace Workspace model instance or null for system scope * @param int $limit Maximum versions to return * @return Collection */ public function getVersions(?object $workspace = null, int $limit = 20): Collection { $workspaceId = $workspace?->id; return ConfigVersion::where('workspace_id', $workspaceId) ->orderByDesc('created_at') ->limit($limit) ->get(); } /** * Get a specific version. * * @param int $versionId Version ID */ public function getVersion(int $versionId): ?ConfigVersion { return ConfigVersion::find($versionId); } /** * Compare two versions. * * @param object|null $workspace Workspace model instance or null for system scope * @param int $oldVersionId Older version ID * @param int $newVersionId Newer version ID * @return VersionDiff Difference between versions * * @throws \InvalidArgumentException If versions not found */ public function compare(?object $workspace, int $oldVersionId, int $newVersionId): VersionDiff { $oldVersion = ConfigVersion::find($oldVersionId); $newVersion = ConfigVersion::find($newVersionId); if ($oldVersion === null) { throw new \InvalidArgumentException("Old version not found: {$oldVersionId}"); } if ($newVersion === null) { throw new \InvalidArgumentException("New version not found: {$newVersionId}"); } // Parse snapshots $oldData = json_decode($oldVersion->snapshot, true)['values'] ?? []; $newData = json_decode($newVersion->snapshot, true)['values'] ?? []; return $this->computeDiff($oldData, $newData); } /** * Compare current state with a version. * * @param object|null $workspace Workspace model instance or null for system scope * @param int $versionId Version ID to compare against * @return VersionDiff Difference between version and current state * * @throws \InvalidArgumentException If version not found */ public function compareWithCurrent(?object $workspace, int $versionId): VersionDiff { $version = ConfigVersion::find($versionId); if ($version === null) { throw new \InvalidArgumentException("Version not found: {$versionId}"); } // Get current state $currentJson = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); $currentData = json_decode($currentJson, true)['values'] ?? []; // Get version state $versionData = json_decode($version->snapshot, true)['values'] ?? []; return $this->computeDiff($versionData, $currentData); } /** * Compute difference between two value arrays. * * @param array $oldValues * @param array $newValues */ protected function computeDiff(array $oldValues, array $newValues): VersionDiff { $diff = new VersionDiff; // Index by key $oldByKey = collect($oldValues)->keyBy('key'); $newByKey = collect($newValues)->keyBy('key'); // Find added keys (in new but not in old) foreach ($newByKey as $key => $newValue) { if (! $oldByKey->has($key)) { $diff->addAdded($key, $newValue['value']); } } // Find removed keys (in old but not in new) foreach ($oldByKey as $key => $oldValue) { if (! $newByKey->has($key)) { $diff->addRemoved($key, $oldValue['value']); } } // Find changed keys (in both but different) foreach ($oldByKey as $key => $oldValue) { if ($newByKey->has($key)) { $newValue = $newByKey[$key]; if ($oldValue['value'] !== $newValue['value']) { $diff->addChanged($key, $oldValue['value'], $newValue['value']); } if (($oldValue['locked'] ?? false) !== ($newValue['locked'] ?? false)) { $diff->addLockChanged($key, $oldValue['locked'] ?? false, $newValue['locked'] ?? false); } } } return $diff; } /** * Delete a version. * * @param int $versionId Version ID * * @throws \InvalidArgumentException If version not found */ public function deleteVersion(int $versionId): void { $version = ConfigVersion::find($versionId); if ($version === null) { throw new \InvalidArgumentException("Version not found: {$versionId}"); } $version->delete(); } /** * Prune old versions beyond retention limit. * * @param int $profileId Profile ID */ protected function pruneOldVersions(int $profileId): void { $versions = ConfigVersion::where('profile_id', $profileId) ->orderByDesc('created_at') ->get(); if ($versions->count() > $this->maxVersions) { $toDelete = $versions->slice($this->maxVersions); foreach ($toDelete as $version) { $version->delete(); } } } /** * Get or create profile for a workspace (or system). */ protected function getOrCreateProfile(?object $workspace): ConfigProfile { if ($workspace !== null) { return ConfigProfile::ensureWorkspace($workspace->id); } return ConfigProfile::ensureSystem(); } /** * Get current author for version attribution. */ protected function getCurrentAuthor(): ?string { // Try to get authenticated user if (function_exists('auth') && auth()->check()) { $user = auth()->user(); return $user->email ?? $user->name ?? (string) $user->id; } // Return null if no user context return null; } /** * Set maximum versions to keep per scope. * * @param int $max Maximum versions */ public function setMaxVersions(int $max): void { $this->maxVersions = max(1, $max); } /** * Get maximum versions to keep per scope. */ public function getMaxVersions(): int { return $this->maxVersions; } }