diff --git a/app/Client/README.md b/app/Client/README.md index ca7e8a2b..ca79d58c 100644 --- a/app/Client/README.md +++ b/app/Client/README.md @@ -219,10 +219,11 @@ use App\Client\Resources\DatabaseClusters\CreateDatabaseClusterRequest; $connector = new Connector('your-api-token'); $response = $connector->send(new CreateDatabaseClusterRequest(new CreateDatabaseClusterRequestData( - type: 'neon_serverless_postgres_18', + type: 'neon_serverless_postgres', + version: '18', name: 'my-database', region: 'us-east-1', - clusterConfig: [ + config: [ 'cu_min' => 0.25, 'cu_max' => 2, 'suspend_seconds' => 300, diff --git a/app/Client/Requests/CreateDatabaseClusterRequestData.php b/app/Client/Requests/CreateDatabaseClusterRequestData.php index 4c491d52..6fd03016 100644 --- a/app/Client/Requests/CreateDatabaseClusterRequestData.php +++ b/app/Client/Requests/CreateDatabaseClusterRequestData.php @@ -9,6 +9,7 @@ public function __construct( public readonly string $name, public readonly string $region, public readonly array $config, + public readonly ?string $version = null, public readonly ?int $clusterId = null, ) { // @@ -18,6 +19,7 @@ public function toRequestData(): array { return $this->filter([ 'type' => $this->type, + 'version' => $this->version, 'name' => $this->name, 'region' => $this->region, 'config' => $this->config, diff --git a/app/Commands/DatabaseClusterCreate.php b/app/Commands/DatabaseClusterCreate.php index 391fe258..6ddb7f7d 100644 --- a/app/Commands/DatabaseClusterCreate.php +++ b/app/Commands/DatabaseClusterCreate.php @@ -17,7 +17,8 @@ class DatabaseClusterCreate extends BaseCommand protected $signature = 'database-cluster:create {--name= : Database cluster name} - {--type= : Database type} + {--type= : Database type (laravel_mysql, neon_serverless_postgres)} + {--engine-version= : Database engine version (e.g. 18, 8.4). Default: newest available} {--region= : Database region}'; protected $description = 'Create a new database cluster'; diff --git a/app/Commands/Ship.php b/app/Commands/Ship.php index d42d714e..b57abe37 100644 --- a/app/Commands/Ship.php +++ b/app/Commands/Ship.php @@ -60,7 +60,7 @@ class Ship extends BaseCommand use UpdatesBuildDeployCommands; protected $signature = 'ship - {--database= : Database type or alias (postgres, postgres18, postgres17, mysql, or a full type like neon_serverless_postgres_18). Default: postgres18} + {--database= : Database type or alias (postgres, postgres18, postgres17, mysql, or a full type like neon_serverless_postgres). Unversioned aliases use the newest available version. Default: postgres} {--database-preset= : Preset tier for the database (dev, prod, scale — case-insensitive). Default: dev} {--name= : Application name (non-interactive). Default: derived from repository} {--region= : Region (non-interactive). Default: most-used or us-east-2} @@ -591,30 +591,43 @@ function ($errors) use ($environmentParams, $environment) { } } - protected function resolveDatabaseType(): ?string + /** + * Resolve the `--database` option into a type and, when the option names one, a version. + * + * A null version means "the newest the API offers for that type", so `postgres` + * keeps tracking new releases while `postgres17` stays pinned. + * + * @return array{0: string, 1: string|null}|null + */ + protected function resolveDatabaseType(): ?array { - $aliases = [ - 'postgres' => DatabaseClusterPreset::NeonServerlessPostgres18->value, - 'postgres18' => DatabaseClusterPreset::NeonServerlessPostgres18->value, - 'postgres17' => DatabaseClusterPreset::NeonServerlessPostgres17->value, - 'mysql' => DatabaseClusterPreset::LaravelMysql8->value, - ]; + $input = strtolower((string) $this->option('database')); + + if ($input === '') { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, null]; + } - $input = $this->option('database'); + if ($input === 'postgres') { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, null]; + } - if ($input === null || $input === '') { - return DatabaseClusterPreset::NeonServerlessPostgres18->value; + if ($input === 'mysql') { + return [DatabaseClusterPreset::LaravelMysql->value, null]; } - if (isset($aliases[strtolower($input)])) { - return $aliases[strtolower($input)]; + if (preg_match('/^postgres(\d+)$/', $input, $matches)) { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, $matches[1]]; } if (DatabaseClusterPreset::tryFrom($input) !== null) { - return $input; + return [$input, null]; } - $validValues = implode(', ', [...array_keys($aliases), ...array_map(fn (DatabaseClusterPreset $e) => $e->value, DatabaseClusterPreset::cases())]); + if (($legacy = DatabaseClusterPreset::fromLegacyType($input)) !== null) { + return [$legacy[0]->value, $legacy[1]]; + } + + $validValues = implode(', ', ['postgres', 'postgres (e.g. postgres18)', 'mysql', ...array_map(fn (DatabaseClusterPreset $e) => $e->value, DatabaseClusterPreset::cases())]); $this->outputErrorOrThrow('Invalid --database value "'.$input.'". Must be one of: '.$validValues); @@ -645,18 +658,23 @@ protected function provisionDatabaseOpinionated(): ?string $types = $this->client->databaseClusters()->types(); $types = collect($types)->filter(fn (DatabaseType $type) => DatabaseClusterPreset::tryFrom($type->type) !== null)->values(); - $resolvedType = $this->resolveDatabaseType(); + [$resolvedType, $resolvedVersion] = $this->resolveDatabaseType(); $type = $types->firstWhere('type', $resolvedType); if ($type === null) { - if ($resolvedType === DatabaseClusterPreset::NeonServerlessPostgres18->value) { - $type = $types->firstWhere('type', DatabaseClusterPreset::NeonServerlessPostgres17->value); - } + $this->outputErrorOrThrow('Database type "'.$resolvedType.'" is not available from the API.'); + } - if ($type === null) { - $this->outputErrorOrThrow('Database type "'.$resolvedType.'" is not available from the API.'); - } + $version = $resolvedVersion ?? $type->latestVersion(); + + if ($version === null || ! in_array($version, $type->versions, true)) { + $this->outputErrorOrThrow(sprintf( + 'Version "%s" is not available for database type "%s". Available versions: %s', + $version ?? 'unknown', + $type->type, + implode(', ', $type->versions) ?: 'none', + )); } $preset = $this->resolveDatabasePreset($type->type); @@ -669,7 +687,7 @@ protected function provisionDatabaseOpinionated(): ?string $databaseName = $this->appName ? str($this->appName)->snake()->replace('-', '_')->toString() : 'main'; if (! $cluster) { - $cluster = $this->createDatabaseClusterWithOptions($type->type, $preset, $name, $region); + $cluster = $this->createDatabaseClusterWithOptions($type->type, $version, $preset, $name, $region); $cluster = $this->client->databaseClusters()->include('databases')->get($cluster->id); } diff --git a/app/Concerns/CreatesDatabaseCluster.php b/app/Concerns/CreatesDatabaseCluster.php index 930748bf..fc149ee3 100644 --- a/app/Concerns/CreatesDatabaseCluster.php +++ b/app/Concerns/CreatesDatabaseCluster.php @@ -59,6 +59,27 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster $selectedType = $types->firstWhere('type', $this->form()->get('type')); + // Newest first so the default lands on the version most users want. + $versionOptions = collect($selectedType->versions) + ->sort('version_compare') + ->reverse() + ->values(); + + $this->form()->prompt( + 'version', + fn ($resolver) => $resolver + ->fromInput( + fn (?string $value) => select( + label: 'Version', + options: $versionOptions->mapWithKeys(fn (string $version) => [$version => $selectedType->label.' '.$version])->toArray(), + default: $value ?? $defaults['version'] ?? $versionOptions->first(), + required: true, + ), + ) + ->nonInteractively(fn () => $defaults['version'] ?? $versionOptions->first()), + 'engine-version', + ); + $regions = spin( fn () => $this->client->meta()->regions(), 'Fetching regions...', @@ -97,6 +118,8 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster name: $this->form()->get('name'), region: $this->form()->get('region'), config: $config, + // Numeric keys come back from the prompt as integers. + version: (string) $this->form()->get('version'), ), ), 'Creating database cluster...', @@ -105,7 +128,6 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster protected function databaseClusterConfigFromPreset(DatabaseType $type): ?array { - $clusterPreset = DatabaseClusterPreset::from($type->type); $presets = $clusterPreset->presets(); $presets['Custom'] = []; @@ -213,7 +235,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array ])->toArray(); } - protected function createDatabaseClusterWithOptions(string $type, string $preset, string $name, string $region): DatabaseCluster + protected function createDatabaseClusterWithOptions(string $type, string $version, string $preset, string $name, string $region): DatabaseCluster { $enum = DatabaseClusterPreset::tryFrom($type); @@ -240,6 +262,7 @@ protected function createDatabaseClusterWithOptions(string $type, string $preset name: $name, region: $region, config: $config, + version: $version, ), ), 'Creating database cluster...', diff --git a/app/Dto/DatabaseType.php b/app/Dto/DatabaseType.php index aab01e61..ce04e734 100644 --- a/app/Dto/DatabaseType.php +++ b/app/Dto/DatabaseType.php @@ -13,10 +13,28 @@ public function __construct( public readonly array $regions, #[DataCollectionOf(ConfigSchema::class)] public readonly array $configSchema, + /** @var list The engine versions a cluster may be created with. */ + public readonly array $versions = [], ) { // } + /** + * Get the newest version a cluster of this type may be created with. + */ + public function latestVersion(): ?string + { + if ($this->versions === []) { + return null; + } + + $versions = $this->versions; + + usort($versions, 'version_compare'); + + return end($versions); + } + public static function createFromResponse(array $response): self { $data = $response['data'] ?? []; @@ -25,6 +43,7 @@ public static function createFromResponse(array $response): self 'type' => $data['type'], 'label' => $data['label'], 'regions' => $data['regions'] ?? [], + 'versions' => array_values(array_map('strval', $data['versions'] ?? [])), 'configSchema' => collect($data['config_schema'] ?? [])->map(fn (array $schema) => ConfigSchema::from($schema)->toArray())->toArray(), ]); } diff --git a/app/Enums/DatabaseClusterPreset.php b/app/Enums/DatabaseClusterPreset.php index 84276a4d..ea56864e 100644 --- a/app/Enums/DatabaseClusterPreset.php +++ b/app/Enums/DatabaseClusterPreset.php @@ -4,16 +4,51 @@ use Closure; +/** + * The database types the CLI ships opinionated presets for. + * + * The API describes a database by a versionless type plus a separate engine + * version (see GET /databases/types), so the cases here mirror the API's type + * values and carry no version of their own. The retired versioned identifiers + * (`neon_serverless_postgres_18`, `laravel_mysql_8`, ...) are still accepted as + * input and split into a type and version by `fromLegacyType()`. + */ enum DatabaseClusterPreset: string { - case LaravelMysql8 = 'laravel_mysql_8'; - case NeonServerlessPostgres18 = 'neon_serverless_postgres_18'; - case NeonServerlessPostgres17 = 'neon_serverless_postgres_17'; + case LaravelMysql = 'laravel_mysql'; + case NeonServerlessPostgres = 'neon_serverless_postgres'; + + /** + * Resolve a retired versioned type identifier into a type and version. + * + * @return array{0: self, 1: string}|null + */ + public static function fromLegacyType(string $type): ?array + { + if (! preg_match('/^(?[a-z_]+?)_(?\d+)$/', $type, $matches)) { + return null; + } + + $preset = self::tryFrom($matches['type']); + + if ($preset === null) { + return null; + } + + // The retired MySQL types squashed the version into the identifier, but the + // API expects it dotted and 8.4 is the only creatable release. + $version = match ([$preset, $matches['version']]) { + [self::LaravelMysql, '8'], [self::LaravelMysql, '84'] => '8.4', + default => $matches['version'], + }; + + return [$preset, $version]; + } public function presets(): array { return match ($this) { - self::LaravelMysql8 => [ + self::LaravelMysql => [ 'Dev' => [ 'size' => 'db-flex.m-1vcpu-512mb', 'storage' => 5, @@ -36,27 +71,7 @@ public function presets(): array 'is_public' => false, ], ], - self::NeonServerlessPostgres18 => [ - 'Dev' => [ - 'cu_min' => 0.25, - 'cu_max' => 0.25, - 'suspend_seconds' => 300, - 'retention_days' => 0, - ], - 'Prod' => [ - 'cu_min' => 0.25, - 'cu_max' => 1, - 'suspend_seconds' => 0, - 'retention_days' => 7, - ], - 'Scale' => [ - 'cu_min' => 1, - 'cu_max' => 4, - 'suspend_seconds' => 0, - 'retention_days' => 14, - ], - ], - self::NeonServerlessPostgres17 => [ + self::NeonServerlessPostgres => [ 'Dev' => [ 'cu_min' => 0.25, 'cu_max' => 0.25, @@ -82,7 +97,7 @@ public function presets(): array public function description(): Closure { return match ($this) { - self::LaravelMysql8 => fn ($preset) => sprintf( + self::LaravelMysql => fn ($preset) => sprintf( '%s · %sGB storage · %d %s backups', str($preset['size']) ->replaceMatches( @@ -97,18 +112,12 @@ public function description(): Closure $preset['retention_days'], str('day')->plural($preset['retention_days']), ), - self::NeonServerlessPostgres18 => fn ($preset) => sprintf( + self::NeonServerlessPostgres => fn ($preset) => sprintf( '%s vCPU units · %s · %s', $preset['cu_min'] === $preset['cu_max'] ? $this->formatNumber($preset['cu_min']) : $this->formatNumber($preset['cu_min']).' – '.$this->formatNumber($preset['cu_max']), $preset['suspend_seconds'] > 0 ? 'Scale to zero after '.$preset['suspend_seconds'].' seconds' : 'No scale to zero', $preset['retention_days'] === 0 ? 'No backups' : $preset['retention_days'].' days PITR', ), - self::NeonServerlessPostgres17 => fn ($preset) => sprintf( - '%s vCPU units · %s · %s', - $preset['cu_min'] === $preset['cu_max'] ? $this->formatNumber($preset['cu_min']) : $this->formatNumber($preset['cu_min']).' – '.$this->formatNumber($preset['cu_max']), - $preset['suspend_seconds'] === 0 ? 'No scale to zero' : 'Scale to zero after '.$preset['suspend_seconds'].' seconds', - $preset['retention_days'] === 0 ? 'No backups' : $preset['retention_days'].' days PITR', - ), }; } diff --git a/tests/Feature/DatabaseClusterCreateTest.php b/tests/Feature/DatabaseClusterCreateTest.php new file mode 100644 index 00000000..70f953f3 --- /dev/null +++ b/tests/Feature/DatabaseClusterCreateTest.php @@ -0,0 +1,92 @@ +mockConfig = Mockery::mock(ConfigRepository::class); + $this->mockConfig->shouldReceive('apiTokens')->andReturn(collect(['test-api-token'])); + $this->app->instance(ConfigRepository::class, $this->mockConfig); +}); + +afterEach(function () { + MockClient::destroyGlobal(); +}); + +function setupDatabaseClusterCreateMocks(): Closure +{ + $sentBody = new stdClass; + + MockClient::global([ + GetOrganizationRequest::class => MockResponse::make(organizationResponse(), 200), + ListDatabaseTypesRequest::class => MockResponse::make(versionlessDatabaseTypesResponse(), 200), + ListRegionsRequest::class => MockResponse::make(regionsResponse(), 200), + CreateDatabaseClusterRequest::class => function (PendingRequest $request) use ($sentBody) { + $sentBody->value = $request->body()->all(); + + return MockResponse::make(databaseClusterResponse([ + 'attributes' => ['type' => $sentBody->value['type']], + ]), 201); + }, + ]); + + return fn () => $sentBody->value ?? null; +} + +it('creates a cluster on the newest version when none is given', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'neon_serverless_postgres', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18') + ->and($sentBody()['config'])->toBe([ + 'cu_min' => 0.25, + 'cu_max' => 0.25, + 'suspend_seconds' => 300, + 'retention_days' => 0, + ]); +}); + +it('pins the cluster to the requested engine version', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'neon_serverless_postgres', + '--engine-version' => '17', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('17'); +}); + +it('sends the dotted version for MySQL', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('laravel_mysql') + ->and($sentBody()['version'])->toBe('8.4'); +}); diff --git a/tests/Feature/ShipDatabaseTest.php b/tests/Feature/ShipDatabaseTest.php new file mode 100644 index 00000000..4d5a9eda --- /dev/null +++ b/tests/Feature/ShipDatabaseTest.php @@ -0,0 +1,153 @@ +getDefinition(); + $definition->addOption(new InputOption('no-interaction', 'n', InputOption::VALUE_NONE)); + + $input = new ArrayInput([...$options, '--no-interaction' => true], $definition); + $input->setInteractive(false); + + (function () use ($input) { + $this->input = $input; + $this->output = new BufferedOutput; + $this->client = new Connector('test-api-token'); + $this->appName = 'my-app'; + $this->region = 'us-east-1'; + })->call($command); + + return $command; +} + +function setupShipDatabaseMocks(): Closure +{ + $sentBody = new stdClass; + + MockClient::global([ + ListDatabaseTypesRequest::class => MockResponse::make(versionlessDatabaseTypesResponse(), 200), + ListDatabaseClustersRequest::class => MockResponse::make([ + 'data' => [], + 'included' => [], + 'links' => ['next' => null], + ], 200), + CreateDatabaseClusterRequest::class => function (PendingRequest $request) use ($sentBody) { + $sentBody->value = $request->body()->all(); + + return MockResponse::make(databaseClusterResponse([ + 'attributes' => ['type' => $sentBody->value['type']], + ]), 201); + }, + GetDatabaseClusterRequest::class => MockResponse::make(databaseClusterResponse(), 200), + CreateDatabaseRequest::class => MockResponse::make(['data' => databaseSchemaResponse()], 201), + ]); + + return fn () => $sentBody->value ?? null; +} + +it('resolves the database option into a type and version', function (string $option, string $type, ?string $version) { + $command = shipCommandProvisioningDatabase(['--database' => $option]); + + expect((fn () => $this->resolveDatabaseType())->call($command))->toBe([$type, $version]); +})->with([ + 'postgres tracks the newest release' => ['postgres', 'neon_serverless_postgres', null], + 'postgres18 pins the version' => ['postgres18', 'neon_serverless_postgres', '18'], + 'postgres17 pins the version' => ['postgres17', 'neon_serverless_postgres', '17'], + 'mysql tracks the newest release' => ['mysql', 'laravel_mysql', null], + 'a versionless API type' => ['neon_serverless_postgres', 'neon_serverless_postgres', null], + 'a retired versioned Postgres type' => ['neon_serverless_postgres_17', 'neon_serverless_postgres', '17'], + 'a retired versioned MySQL type' => ['laravel_mysql_8', 'laravel_mysql', '8.4'], +]); + +it('defaults to the newest Serverless Postgres the API offers', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase([]); + + $schemaId = (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($schemaId)->toBe('schema-1') + ->and($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18') + ->and($sentBody()['name'])->toBe('my_app') + ->and($sentBody()['region'])->toBe('us-east-1') + ->and($sentBody()['config'])->toBe([ + 'cu_min' => 0.25, + 'cu_max' => 0.25, + 'suspend_seconds' => 300, + 'retention_days' => 0, + ]); +}); + +it('provisions the pinned Postgres version', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'postgres17', '--database-preset' => 'prod']); + + (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('17') + ->and($sentBody()['config']['retention_days'])->toBe(7); +}); + +it('still accepts the retired versioned type identifiers', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'neon_serverless_postgres_18']); + + (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18'); +}); + +it('rejects a version the API does not offer', function () { + setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'postgres15']); + + expect(fn () => (fn () => $this->provisionDatabaseOpinionated())->call($command)) + ->toThrow(RuntimeException::class, 'Version "15" is not available for database type "neon_serverless_postgres". Available versions: 16, 17, 18'); + + MockClient::global()->assertNotSent(CreateDatabaseClusterRequest::class); +}); + +it('rejects a database type it has no presets for', function () { + setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'aws_rds_postgres']); + + expect(fn () => (fn () => $this->provisionDatabaseOpinionated())->call($command)) + ->toThrow(RuntimeException::class, 'Invalid --database value "aws_rds_postgres"'); +}); diff --git a/tests/Helpers.php b/tests/Helpers.php index d5079789..3c2fa6ed 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -372,3 +372,47 @@ function usageResponse(array $overrides = []): array return array_replace_recursive($base, $overrides); } + +/** + * The shape GET /databases/types serves: a versionless type with the creatable + * versions listed alongside it. The CLI must not expect a version in the type. + */ +function versionlessDatabaseTypesResponse(): array +{ + return [ + 'data' => [ + [ + 'type' => 'laravel_mysql', + 'label' => 'Laravel MySQL', + 'versions' => ['8.4'], + 'regions' => ['us-east-1'], + 'config_schema' => [ + ['name' => 'size', 'type' => 'string', 'required' => true, 'example' => 'db-flex.m-1vcpu-512mb'], + ['name' => 'storage', 'type' => 'integer', 'required' => true, 'example' => '5'], + ['name' => 'retention_days', 'type' => 'integer', 'required' => true, 'example' => '1'], + ['name' => 'uses_scheduled_snapshots', 'type' => 'boolean', 'required' => true, 'example' => 'false'], + ['name' => 'is_public', 'type' => 'boolean', 'required' => true, 'example' => 'false'], + ], + ], + [ + 'type' => 'aws_rds_postgres', + 'label' => 'AWS RDS Postgres', + 'versions' => ['18'], + 'regions' => ['us-east-1'], + 'config_schema' => [], + ], + [ + 'type' => 'neon_serverless_postgres', + 'label' => 'Laravel Serverless Postgres', + 'versions' => ['16', '17', '18'], + 'regions' => ['us-east-1'], + 'config_schema' => [ + ['name' => 'cu_min', 'type' => 'number', 'required' => true, 'example' => '0.25'], + ['name' => 'cu_max', 'type' => 'number', 'required' => true, 'example' => '0.25'], + ['name' => 'suspend_seconds', 'type' => 'integer', 'required' => true, 'example' => '300'], + ['name' => 'retention_days', 'type' => 'integer', 'required' => true, 'example' => '0'], + ], + ], + ], + ]; +}