Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/Client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/Client/Requests/CreateDatabaseClusterRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
//
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion app/Commands/DatabaseClusterCreate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
64 changes: 41 additions & 23 deletions app/Commands/Ship.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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<version> (e.g. postgres18)', 'mysql', ...array_map(fn (DatabaseClusterPreset $e) => $e->value, DatabaseClusterPreset::cases())]);

$this->outputErrorOrThrow('Invalid --database value "'.$input.'". Must be one of: '.$validValues);

Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
27 changes: 25 additions & 2 deletions app/Concerns/CreatesDatabaseCluster.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...',
Expand Down Expand Up @@ -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...',
Expand All @@ -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'] = [];
Expand Down Expand Up @@ -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);

Expand All @@ -240,6 +262,7 @@ protected function createDatabaseClusterWithOptions(string $type, string $preset
name: $name,
region: $region,
config: $config,
version: $version,
),
),
'Creating database cluster...',
Expand Down
19 changes: 19 additions & 0 deletions app/Dto/DatabaseType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,28 @@ public function __construct(
public readonly array $regions,
#[DataCollectionOf(ConfigSchema::class)]
public readonly array $configSchema,
/** @var list<string> 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'] ?? [];
Expand All @@ -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(),
]);
}
Expand Down
75 changes: 42 additions & 33 deletions app/Enums/DatabaseClusterPreset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('/^(?<type>[a-z_]+?)_(?<version>\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,
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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',
),
};
}

Expand Down
Loading