diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 057fcfe8..1db1105a 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -861,6 +861,93 @@ prisma-cli database create my-db --branch feature/foo --region eu-central-1 prisma-cli database create my-db --json ``` +## `prisma-cli database usage --project --branch --from --to ` + +Purpose: + +- show usage metrics for one Prisma Postgres database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- `--branch ` narrows name resolution when the same database name exists on multiple Branches +- `--from ` and `--to ` bound the reporting period; when omitted, the platform defaults the period to the current month so far +- `--from` and `--to` accept a calendar date (`2026-06-01`) or an ISO datetime (`2026-06-01T12:00:00Z`); a calendar-date `--from` expands to the start of its UTC day and a calendar-date `--to` expands to the end of its UTC day, so `--from 2026-06-01 --to 2026-06-30` is a calendar-day-inclusive June range without relying on server-side end-of-day handling +- the CLI rejects malformed or impossible calendar dates (for example `2026-02-30`) and a `--from` later than `--to` before calling the API +- reports operations used (`ops`) and storage used (`GiB`) for the period, plus the resolved period bounds and the generation timestamp +- read-only; never prints or returns connection strings, passwords, or endpoint secrets +- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when the target cannot be selected safely + +Examples: + +```bash +prisma-cli database usage db_123 +prisma-cli database usage acme-production --from 2026-06-01 --to 2026-06-30 +prisma-cli database usage db_123 --json +``` + +## `prisma-cli database backup` + +Manage backups for a database. `backup` is nested under `database` because +backups exist only in the context of one Prisma Postgres database, following +the same parent-noun/subordinate-noun/action shape as `database connection`. +The platform creates backups automatically; the first slice is read-only. + +### `prisma-cli database backup list --limit ` + +Purpose: + +- list backups for a database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- supports `--branch ` to narrow database name resolution +- `--limit ` caps the number of returned backups; the platform accepts 1 to 100 and defaults to 25 +- lists backup id, type (`full` or `incremental`), status (`running`, `completed`, `failed`, or `unknown`), size when available, and created timestamp +- includes the platform's backup retention window in days +- read-only; never prints or returns secret values +- fails with `DATABASE_BACKUPS_UNSUPPORTED` when the platform reports backups are not available for the database (for example remote/BYO databases) +- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when the target cannot be selected safely + +Examples: + +```bash +prisma-cli database backup list db_123 +prisma-cli database backup list acme-production --limit 50 +prisma-cli database backup list db_123 --json +``` + +## `prisma-cli database restore --backup --source-database --confirm ` + +Purpose: + +- restore a database's data from a backup + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- restores into the resolved `` (the target): all current data is immediately and irreversibly overwritten with the backup contents; connections and credentials are preserved, so no new connection URL is printed +- resolves `` by exact database id or exact database name inside the resolved project; `--branch ` narrows name resolution +- `--backup ` is required and names the backup to restore from; ids come from `database backup list` +- by default the backup must belong to the target database; `--source-database ` restores from another database's backup, for example a production backup into a scratch database, and requires access to both databases' projects +- requires `--confirm ` where the value exactly matches the resolved target database id; `--yes` does not satisfy this confirmation +- the restore runs asynchronously: the target database status becomes `recovering` until the restore completes, and `database show` reports the current status; `nextSteps` includes the show command +- fails with `DATABASE_BACKUP_NOT_FOUND` when the backup id cannot be resolved for the source database +- fails with `DATABASE_RESTORE_CONFLICT` when the target database is provisioning or already recovering +- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when a database target cannot be selected safely +- never prints or returns connection strings, passwords, or endpoint secrets + +Examples: + +```bash +prisma-cli database restore db_123 --backup bkp_456 --confirm db_123 +prisma-cli database restore scratch --backup bkp_456 --source-database acme-production --confirm db_789 +prisma-cli database restore db_123 --backup bkp_456 --confirm db_123 --json +``` + ## `prisma-cli database remove --confirm ` Purpose: @@ -891,7 +978,8 @@ valid in the context of a Prisma Postgres database. The subgroup mirrors the `project env ` shape: the parent command names the resource family, the nested noun names the subordinate resource, and the final token is the action. There is no `database connection show` command because connection -strings are one-time-view secrets. +strings are one-time-view secrets: the platform returns them only from +`connection create` and `connection rotate`, never from read endpoints. ### `prisma-cli database connection list ` @@ -944,6 +1032,34 @@ prisma-cli database connection create db_123 --name readonly prisma-cli database connection create db_123 --json ``` +### `prisma-cli database connection rotate --confirm ` + +Purpose: + +- rotate a database connection's credentials and print the new one-time connection URL + +Behavior: + +- requires auth +- treats `` as the connection id to rotate +- requires `--confirm ` where the value exactly matches the connection id; `--yes` does not satisfy this confirmation, because rotation revokes the previous credentials (best-effort) and breaks clients still using them +- mints new credentials for the same connection; the connection id and name are unchanged +- the new connection URL is a one-time-view secret with the same output contract as `database connection create`: + - in default human mode, stderr shows a short rotation summary and stdout contains exactly one line, the raw new connection URL + - human stderr does not repeat, label, or wrap the connection URL + - `--verbose` adds human-only metadata rows such as database and connection id on stderr before the URL is written to stdout + - `--quiet` suppresses successful stderr output and leaves stdout as exactly the raw connection URL + - in `--json`, `result.connectionString` contains the raw one-time URL exactly once +- no `DATABASE_URL=` or `DIRECT_URL=` formatting is added; consumers decide how to store the URL +- fails with `DATABASE_CONNECTION_NOT_FOUND` when the connection does not exist + +Examples: + +```bash +prisma-cli database connection rotate conn_123 --confirm conn_123 +prisma-cli database connection rotate conn_123 --confirm conn_123 --json +``` + ### `prisma-cli database connection remove --confirm ` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 757bab3f..83fdd2ac 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -213,6 +213,9 @@ These codes are the minimum stable set for the MVP: - `DATABASE_CONNECTION_MISSING` - `DATABASE_CONNECTION_STRING_MISSING` - `DATABASE_API_ERROR` +- `DATABASE_BACKUPS_UNSUPPORTED` +- `DATABASE_BACKUP_NOT_FOUND` +- `DATABASE_RESTORE_CONFLICT` - `RUN_FAILED` - `DEPLOY_FAILED` - `VERSION_UNAVAILABLE` @@ -275,6 +278,9 @@ Recommended meanings: - `DATABASE_CONNECTION_MISSING`: database creation succeeded but the API response did not include the first one-time connection payload - `DATABASE_CONNECTION_STRING_MISSING`: connection creation succeeded but the API response did not include the one-time connection string - `DATABASE_API_ERROR`: database Management API request failed without a more specific CLI error code +- `DATABASE_BACKUPS_UNSUPPORTED`: the platform does not manage backups for the database, for example remote/BYO databases +- `DATABASE_BACKUP_NOT_FOUND`: requested backup id does not exist for the resolved source database +- `DATABASE_RESTORE_CONFLICT`: restore target database is provisioning or already recovering - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed - `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index ae19cb82..643e14d8 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -156,22 +156,26 @@ top-level target-context group is `branch`, not `env`. resource. The beta package exposes `database` as the canonical database management group. -The first slice manages Prisma Postgres database metadata and one-time-view +It manages Prisma Postgres database metadata, usage, backups, and one-time-view connection strings: - `database list` - `database show ` - `database create ` +- `database usage ` +- `database restore ` - `database remove ` +- `database backup list ` - `database connection list ` - `database connection create ` +- `database connection rotate ` - `database connection remove ` -The `database connection` subgroup is nested because connection strings exist -only for databases. It follows the same parent-noun/subordinate-noun/action -shape as `project env `. There is no `database connection show` command: -connection strings are secrets and the platform returns them only during create -operations. +The `database connection` and `database backup` subgroups are nested because +connections and backups exist only for databases. They follow the same +parent-noun/subordinate-noun/action shape as `project env `. There is +no `database connection show` command: connection strings are secrets and the +platform returns them only from create and rotate operations. Rules: @@ -185,6 +189,13 @@ Rules: or return secret values - database and database connection removal require exact id confirmation with `--confirm `; `--yes` is not sufficient +- database restore and connection rotation are equally destructive (restore + overwrites the target's data; rotation revokes the previous credentials) and + require the same exact id confirmation with `--confirm ` +- backups are platform-created; the CLI lists them and restores from them but + never creates or deletes them in the current slice +- `database usage` and `database backup list` are read-only and never print + secret values - preview Branch setup writes branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides, not separate app bindings - first production deploy setup writes production `DATABASE_URL` and `DIRECT_URL` env vars before the App has a live deployment - database setup never overwrites an existing branch-scoped `DATABASE_URL` diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index ebc0b832..abe2791a 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -183,5 +183,45 @@ "createdAt": "2026-06-02T00:00:00.000Z", "connectionString": "postgresql://secret-production.example.prisma.io/postgres" } + ], + "databaseBackups": [ + { + "id": "bkp_101", + "databaseId": "db_123", + "backupType": "full", + "status": "completed", + "size": 1048576, + "createdAt": "2026-06-20T00:00:00.000Z" + }, + { + "id": "bkp_102", + "databaseId": "db_123", + "backupType": "incremental", + "status": "completed", + "createdAt": "2026-06-21T00:00:00.000Z" + }, + { + "id": "bkp_201", + "databaseId": "db_456", + "backupType": "full", + "status": "completed", + "size": 5242880, + "createdAt": "2026-06-22T00:00:00.000Z" + } + ], + "databaseBackupRetentionDays": 35, + "databaseUsage": [ + { + "databaseId": "db_123", + "period": { + "start": "2026-06-01T00:00:00.000Z", + "end": "2026-06-30T23:59:59.999Z" + }, + "metrics": { + "operations": { "used": 12500, "unit": "ops" }, + "storage": { "used": 1.25, "unit": "GiB" } + }, + "generatedAt": "2026-07-01T00:00:00.000Z" + } ] } diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 5e5f3ff9..7d851ddb 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -69,6 +69,25 @@ interface DatabaseConnectionRecord { connectionString?: string; } +interface DatabaseBackupRecord { + id: string; + databaseId: string; + backupType: string; + status: string; + size?: number; + createdAt: string; +} + +interface DatabaseUsageRecord { + databaseId: string; + period: { start: string; end: string }; + metrics: { + operations: { used: number; unit: string }; + storage: { used: number; unit: string }; + }; + generatedAt: string; +} + interface MockApiData { providers: ProviderRecord[]; users: UserRecord[]; @@ -79,6 +98,9 @@ interface MockApiData { deployments: DeploymentRecord[]; databases?: DatabaseRecord[]; databaseConnections?: DatabaseConnectionRecord[]; + databaseBackups?: DatabaseBackupRecord[]; + databaseUsage?: DatabaseUsageRecord[]; + databaseBackupRetentionDays?: number; } export class MockApi { @@ -317,6 +339,116 @@ export class MockApi { ); return connection; } + + getDatabaseUsage( + databaseId: string, + period?: { from?: string; to?: string }, + ): { + period: { start: string; end: string }; + metrics: { + operations: { used: number; unit: string }; + storage: { used: number; unit: string }; + }; + generatedAt: string; + } { + const usage = (this.data.databaseUsage ?? []).find( + (record) => record.databaseId === databaseId, + ); + const defaults = usage ?? { + databaseId, + period: { + start: "2026-06-01T00:00:00.000Z", + end: "2026-06-30T23:59:59.999Z", + }, + metrics: { + operations: { used: 0, unit: "ops" }, + storage: { used: 0, unit: "GiB" }, + }, + generatedAt: "2026-07-01T00:00:00.000Z", + }; + + return { + period: { + start: period?.from ?? defaults.period.start, + end: period?.to ?? defaults.period.end, + }, + metrics: defaults.metrics, + generatedAt: defaults.generatedAt, + }; + } + + listDatabaseBackups( + databaseId: string, + limit?: number, + ): { + backups: Array<{ + id: string; + backupType: string; + status: string; + size: number | null; + createdAt: string; + }>; + retentionDays: number | null; + hasMore: boolean; + } { + const backups = (this.data.databaseBackups ?? []).filter( + (backup) => backup.databaseId === databaseId, + ); + const limited = limit === undefined ? backups : backups.slice(0, limit); + + return { + backups: limited.map((backup) => ({ + id: backup.id, + backupType: backup.backupType, + status: backup.status, + size: backup.size ?? null, + createdAt: backup.createdAt, + })), + retentionDays: this.data.databaseBackupRetentionDays ?? null, + hasMore: limited.length < backups.length, + }; + } + + restoreDatabase(input: { + targetDatabaseId: string; + sourceDatabaseId: string; + backupId: string; + }): + | { outcome: "restored"; database: DatabaseRecord } + | { outcome: "target-not-found" } + | { outcome: "backup-not-found" } { + const target = this.getDatabase(input.targetDatabaseId); + if (!target) { + return { outcome: "target-not-found" }; + } + + const backup = (this.data.databaseBackups ?? []).find( + (candidate) => + candidate.id === input.backupId && + candidate.databaseId === input.sourceDatabaseId, + ); + if (!backup) { + return { outcome: "backup-not-found" }; + } + + target.status = "recovering"; + return { outcome: "restored", database: target }; + } + + rotateDatabaseConnection( + connectionId: string, + ): + | { connection: DatabaseConnectionRecord; connectionString: string } + | undefined { + const connection = this.getDatabaseConnection(connectionId); + if (!connection) { + return undefined; + } + + const connectionString = `postgresql://rotated-${connection.databaseId}-${connection.id}.example.prisma.io/postgres`; + connection.connectionString = connectionString; + return { connection, connectionString }; + } } export type { diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 22efb4b9..e1771a5b 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -1,31 +1,44 @@ import { Command, Option } from "commander"; import { + runDatabaseBackupList, runDatabaseConnectionCreate, runDatabaseConnectionList, runDatabaseConnectionRemove, + runDatabaseConnectionRotate, runDatabaseCreate, runDatabaseList, runDatabaseRemove, + runDatabaseRestore, runDatabaseShow, + runDatabaseUsage, } from "../../controllers/database"; import { + renderDatabaseBackupList, renderDatabaseConnectionCreate, renderDatabaseConnectionCreateStdout, renderDatabaseConnectionList, renderDatabaseConnectionRemove, + renderDatabaseConnectionRotate, + renderDatabaseConnectionRotateStdout, renderDatabaseCreate, renderDatabaseCreateStdout, renderDatabaseList, renderDatabaseRemove, + renderDatabaseRestore, renderDatabaseShow, + renderDatabaseUsage, + serializeDatabaseBackupList, serializeDatabaseConnectionCreate, serializeDatabaseConnectionList, serializeDatabaseConnectionRemove, + serializeDatabaseConnectionRotate, serializeDatabaseCreate, serializeDatabaseList, serializeDatabaseRemove, + serializeDatabaseRestore, serializeDatabaseShow, + serializeDatabaseUsage, } from "../../presenters/database"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; @@ -35,13 +48,17 @@ import { } from "../../shell/global-flags"; import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; import type { + DatabaseBackupListResult, DatabaseConnectionCreateResult, DatabaseConnectionListResult, DatabaseConnectionRemoveResult, + DatabaseConnectionRotateResult, DatabaseCreateResult, DatabaseListResult, DatabaseRemoveResult, + DatabaseRestoreResult, DatabaseShowResult, + DatabaseUsageResult, } from "../../types/database"; export function createDatabaseCommand(runtime: CliRuntime): Command { @@ -55,7 +72,10 @@ export function createDatabaseCommand(runtime: CliRuntime): Command { database.addCommand(createDatabaseListCommand(runtime)); database.addCommand(createDatabaseShowCommand(runtime)); database.addCommand(createDatabaseCreateCommand(runtime)); + database.addCommand(createDatabaseUsageCommand(runtime)); + database.addCommand(createDatabaseRestoreCommand(runtime)); database.addCommand(createDatabaseRemoveCommand(runtime)); + database.addCommand(createDatabaseBackupCommand(runtime)); database.addCommand(createDatabaseConnectionCommand(runtime)); return database; @@ -163,6 +183,155 @@ function createDatabaseCreateCommand(runtime: CliRuntime): Command { return command; } +function createDatabaseUsageCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("usage"), runtime), + "database.usage", + ); + + command + .argument("", "Database id or name") + .addOption(new Option("--from ", "Start of the usage period")) + .addOption(new Option("--to ", "End of the usage period")); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const from = (options as { from?: string }).from; + const to = (options as { to?: string }).to; + + await runCommand( + runtime, + "database.usage", + options as Record, + (context) => + runDatabaseUsage(context, databaseRef, { + projectRef, + branchName, + from, + to, + }), + { + renderHuman: (context, descriptor, result) => + renderDatabaseUsage(context, descriptor, result), + renderJson: (result) => serializeDatabaseUsage(result), + }, + ); + }); + + return command; +} + +function createDatabaseRestoreCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("restore"), runtime), + "database.restore", + ); + + command + .argument("", "Target database id or name") + .addOption(new Option("--backup ", "Backup to restore from")) + .addOption( + new Option( + "--source-database ", + "Database the backup belongs to (defaults to the target)", + ), + ) + .addOption( + new Option( + "--confirm ", + "Exact target database id required to restore", + ), + ); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const backupId = (options as { backup?: string }).backup; + const sourceDatabaseRef = (options as { sourceDatabase?: string }) + .sourceDatabase; + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "database.restore", + options as Record, + (context) => + runDatabaseRestore(context, databaseRef, { + projectRef, + branchName, + backupId, + sourceDatabaseRef, + confirm, + }), + { + renderHuman: (context, descriptor, result) => + renderDatabaseRestore(context, descriptor, result), + renderJson: (result) => serializeDatabaseRestore(result), + }, + ); + }); + + return command; +} + +function createDatabaseBackupCommand(runtime: CliRuntime): Command { + const backup = attachCommandDescriptor( + configureRuntimeCommand(new Command("backup"), runtime), + "database.backup", + ); + + addCompactGlobalFlags(backup); + + backup.addCommand(createDatabaseBackupListCommand(runtime)); + + return backup; +} + +function createDatabaseBackupListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("list"), runtime), + "database.backup.list", + ); + + command + .argument("", "Database id or name") + .addOption( + new Option("--limit ", "Maximum number of backups to return"), + ); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const limit = (options as { limit?: string }).limit; + + await runCommand( + runtime, + "database.backup.list", + options as Record, + (context) => + runDatabaseBackupList(context, databaseRef, { + projectRef, + branchName, + limit, + }), + { + renderHuman: (context, descriptor, result) => + renderDatabaseBackupList(context, descriptor, result), + renderJson: (result) => serializeDatabaseBackupList(result), + }, + ); + }); + + return command; +} + function createDatabaseRemoveCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("remove"), runtime), @@ -216,11 +385,50 @@ function createDatabaseConnectionCommand(runtime: CliRuntime): Command { connection.addCommand(createDatabaseConnectionListCommand(runtime)); connection.addCommand(createDatabaseConnectionCreateCommand(runtime)); + connection.addCommand(createDatabaseConnectionRotateCommand(runtime)); connection.addCommand(createDatabaseConnectionRemoveCommand(runtime)); return connection; } +function createDatabaseConnectionRotateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("rotate"), runtime), + "database.connection.rotate", + ); + + command + .argument("", "Connection id") + .addOption( + new Option( + "--confirm ", + "Exact connection id required to rotate", + ), + ); + addGlobalFlags(command); + + command.action(async (connectionRef: string, options) => { + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "database.connection.rotate", + options as Record, + (context) => + runDatabaseConnectionRotate(context, connectionRef, { confirm }), + { + renderStdout: (context, descriptor, result) => + renderDatabaseConnectionRotateStdout(context, descriptor, result), + renderHuman: (context, descriptor, result) => + renderDatabaseConnectionRotate(context, descriptor, result), + renderJson: (result) => serializeDatabaseConnectionRotate(result), + }, + ); + }); + + return command; +} + function createDatabaseConnectionListCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("list"), runtime), diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index eebde1c2..4886764d 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -1,5 +1,9 @@ import { randomBytes } from "node:crypto"; +import { + type PrismaCliPackageCommandFormatter, + resolvePrismaCliPackageCommandFormatterSync, +} from "../lib/agent/cli-command"; import { requireComputeAuth } from "../lib/auth/guard"; import { createManagementDatabaseProvider, @@ -21,14 +25,18 @@ import { import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; import type { + DatabaseBackupListResult, DatabaseConnectionCreateResult, DatabaseConnectionListResult, DatabaseConnectionRemoveResult, + DatabaseConnectionRotateResult, DatabaseCreateResult, DatabaseListResult, DatabaseRemoveResult, + DatabaseRestoreResult, DatabaseShowResult, DatabaseSummary, + DatabaseUsageResult, } from "../types/database"; import { requireAuthenticatedAuthState } from "./auth"; import { @@ -57,6 +65,25 @@ interface DatabaseConnectionRemoveFlags { confirm?: string; } +interface DatabaseUsageFlags extends DatabaseCommandFlags { + from?: string; + to?: string; +} + +interface DatabaseBackupListFlags extends DatabaseCommandFlags { + limit?: string; +} + +interface DatabaseRestoreFlags extends DatabaseCommandFlags { + backupId?: string; + sourceDatabaseRef?: string; + confirm?: string; +} + +interface DatabaseConnectionRotateFlags { + confirm?: string; +} + interface ResolvedDatabaseContext { provider: DatabaseProvider; target: ResolvedProjectTarget; @@ -335,6 +362,347 @@ export async function runDatabaseConnectionRemove( }; } +export async function runDatabaseUsage( + context: CommandContext, + databaseRef: string, + flags: DatabaseUsageFlags, +): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const from = parseUsageDate(flags.from, "--from", "start", formatCommand); + const to = parseUsageDate(flags.to, "--to", "end", formatCommand); + if (from && to && Date.parse(from) > Date.parse(to)) { + throw usageError( + "Invalid usage period", + "--from must not be later than --to.", + "Pass a --from date that is on or before the --to date.", + [ + formatCommand([ + "database", + "usage", + "", + "--from", + "2026-06-01", + "--to", + "2026-06-30", + ]), + ], + "database", + ); + } + + const { provider, target } = await requireDatabaseContext( + context, + flags, + "database usage", + ); + const database = await resolveDatabase( + provider, + target, + databaseRef, + flags.branchName, + context.runtime.signal, + ); + const usage = await provider.getUsage(database.id, { + from, + to, + signal: context.runtime.signal, + }); + + return { + command: "database.usage", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + period: usage.period, + metrics: usage.metrics, + generatedAt: usage.generatedAt, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseBackupList( + context: CommandContext, + databaseRef: string, + flags: DatabaseBackupListFlags, +): Promise> { + const limit = parseBackupLimit( + flags.limit, + resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd), + ); + + const { provider, target } = await requireDatabaseContext( + context, + flags, + "database backup list", + ); + const database = await resolveDatabase( + provider, + target, + databaseRef, + flags.branchName, + context.runtime.signal, + ); + const backups = await provider.listBackups(database.id, { + limit, + signal: context.runtime.signal, + }); + + return { + command: "database.backup.list", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + backups: backups.backups, + retentionDays: backups.retentionDays, + hasMore: backups.hasMore, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseRestore( + context: CommandContext, + databaseRef: string, + flags: DatabaseRestoreFlags, +): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const backupId = flags.backupId?.trim(); + if (!backupId) { + throw usageError( + "Backup id required", + "Database restore needs the backup to restore from.", + `Pass --backup from ${formatCommand(["database", "backup", "list", ""])}.`, + [formatCommand(["database", "backup", "list", ""])], + "database", + ); + } + + const { provider, target } = await requireDatabaseContext( + context, + flags, + "database restore", + ); + const database = await resolveDatabase( + provider, + target, + databaseRef, + flags.branchName, + context.runtime.signal, + ); + const sourceDatabase = flags.sourceDatabaseRef + ? await resolveDatabase( + provider, + target, + flags.sourceDatabaseRef, + flags.branchName, + context.runtime.signal, + ) + : database; + + const sourceDatabaseArg = + sourceDatabase.id === database.id + ? "" + : ` --source-database ${sourceDatabase.id}`; + requireExactConfirmation({ + resourceName: "database", + commandName: "database restore", + id: database.id, + confirm: flags.confirm, + summary: "Confirm database restore", + why: "Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database id.", + nextStep: `${formatCommand(["database", "restore", database.id, "--backup", backupId])}${sourceDatabaseArg} --confirm ${database.id}`, + }); + + const restored = await provider.restoreDatabase({ + targetDatabaseId: database.id, + sourceDatabaseId: sourceDatabase.id, + backupId, + projectId: target.project.id, + signal: context.runtime.signal, + }); + + return { + command: "database.restore", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database: ensureProjectId(restored, target.project.id), + source: { + databaseId: sourceDatabase.id, + backupId, + }, + }, + warnings: [], + nextSteps: [formatCommand(["database", "show", database.id])], + }; +} + +export async function runDatabaseConnectionRotate( + context: CommandContext, + connectionRef: string, + flags: DatabaseConnectionRotateFlags, +): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const connectionId = connectionRef.trim(); + if (!connectionId) { + throw usageError( + "Connection id required", + "Database connection rotation needs a connection id.", + "Pass the connection id to rotate.", + [ + formatCommand([ + "database", + "connection", + "rotate", + "", + "--confirm", + "", + ]), + ], + "database", + ); + } + + requireExactConfirmation({ + resourceName: "database connection", + commandName: "database connection rotate", + id: connectionId, + confirm: flags.confirm, + summary: "Confirm database connection rotation", + why: "Rotating revokes the previous credentials and breaks clients still using them, so it requires the exact connection id.", + nextStep: formatCommand([ + "database", + "connection", + "rotate", + connectionId, + "--confirm", + connectionId, + ]), + }); + + const provider = await requireDatabaseProviderOnly(context); + const rotated = await provider.rotateConnection(connectionId, { + signal: context.runtime.signal, + }); + + return { + command: "database.connection.rotate", + result: { + connection: rotated.connection, + database: rotated.database, + connectionString: rotated.connectionString, + }, + warnings: [], + nextSteps: [], + }; +} + +const USAGE_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const USAGE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T/; + +function parseUsageDate( + value: string | undefined, + flagName: string, + dayBoundary: "start" | "end", + formatCommand: PrismaCliPackageCommandFormatter, +): string | undefined { + if (value === undefined) { + return undefined; + } + + // The Management API validates startDate/endDate as full ISO datetimes, so + // date-only input is expanded to a full UTC day boundary: --from to the + // start of its day, --to to the end, keeping `--from X --to Y` a + // calendar-day-inclusive range without relying on server-side end-of-day + // handling. Date.parse alone is too permissive: it rolls over invalid + // calendar dates such as 2026-02-30, so the date part must round-trip + // through toISOString unchanged. + const trimmed = value.trim(); + if (USAGE_DATE_ONLY_PATTERN.test(trimmed) && isValidCalendarDate(trimmed)) { + return dayBoundary === "start" + ? `${trimmed}T00:00:00.000Z` + : `${trimmed}T23:59:59.999Z`; + } + if ( + USAGE_DATETIME_PATTERN.test(trimmed) && + !Number.isNaN(Date.parse(trimmed)) && + isValidCalendarDate(trimmed.slice(0, 10)) + ) { + return trimmed; + } + + throw usageError( + "Invalid usage period", + `${flagName} must be an ISO date such as 2026-06-01 or an ISO datetime such as 2026-06-01T12:00:00Z.`, + `Pass an ISO date or datetime to ${flagName}.`, + [ + formatCommand([ + "database", + "usage", + "", + "--from", + "2026-06-01", + "--to", + "2026-06-30", + ]), + ], + "database", + ); +} + +function isValidCalendarDate(datePart: string): boolean { + const timestamp = Date.parse(`${datePart}T00:00:00.000Z`); + return ( + !Number.isNaN(timestamp) && + new Date(timestamp).toISOString().startsWith(datePart) + ); +} + +function parseBackupLimit( + value: string | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): number | undefined { + if (value === undefined) { + return undefined; + } + + const limit = Number(value.trim()); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw usageError( + "Invalid backup limit", + "--limit must be an integer between 1 and 100.", + "Pass a --limit between 1 and 100.", + [ + formatCommand([ + "database", + "backup", + "list", + "", + "--limit", + "50", + ]), + ], + "database", + ); + } + + return limit; +} + async function requireDatabaseContext( context: CommandContext, flags: DatabaseCommandFlags, @@ -368,7 +736,11 @@ async function requireDatabaseContext( } return { - provider: createManagementDatabaseProvider(client), + provider: createManagementDatabaseProvider(client, { + formatCommand: resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ), + }), target: targetResult.value, }; } @@ -403,7 +775,11 @@ async function requireDatabaseProviderOnly( if (!client) { throw authRequiredError(); } - return createManagementDatabaseProvider(client); + return createManagementDatabaseProvider(client, { + formatCommand: resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ), + }); } return createFixtureDatabaseProvider(context); @@ -477,6 +853,58 @@ function createFixtureDatabaseProvider( throw connectionNotFoundError(connectionId); } }, + + async getUsage(databaseId, options) { + if (!context.api.getDatabase(databaseId)) { + throw databaseNotFoundError(databaseId); + } + return context.api.getDatabaseUsage(databaseId, { + from: options?.from, + to: options?.to, + }); + }, + + async listBackups(databaseId, options) { + if (!context.api.getDatabase(databaseId)) { + throw databaseNotFoundError(databaseId); + } + return context.api.listDatabaseBackups(databaseId, options?.limit); + }, + + async restoreDatabase(options) { + const restored = context.api.restoreDatabase({ + targetDatabaseId: options.targetDatabaseId, + sourceDatabaseId: options.sourceDatabaseId, + backupId: options.backupId, + }); + if (restored.outcome === "target-not-found") { + throw databaseNotFoundError(options.targetDatabaseId); + } + if (restored.outcome === "backup-not-found") { + throw backupNotFoundError( + options.backupId, + options.sourceDatabaseId, + resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd), + ); + } + return normalizeDatabase(restored.database, options.projectId); + }, + + async rotateConnection(connectionId) { + const rotated = context.api.rotateDatabaseConnection(connectionId); + if (!rotated) { + throw connectionNotFoundError(connectionId); + } + const database = context.api.getDatabase(rotated.connection.databaseId); + return { + connection: normalizeConnection( + rotated.connection, + rotated.connection.databaseId, + ), + database: database ? { id: database.id, name: database.name } : null, + connectionString: rotated.connectionString, + }; + }, }; } @@ -549,6 +977,9 @@ function requireExactConfirmation(options: { commandName: string; id: string; confirm: string | undefined; + summary?: string; + why?: string; + nextStep?: string; }): void { if (options.confirm === options.id) { return; @@ -557,12 +988,15 @@ function requireExactConfirmation(options: { throw new CliError({ code: "CONFIRMATION_REQUIRED", domain: "database", - summary: `Confirm ${options.resourceName} removal`, - why: `Removing this ${options.resourceName} is destructive and requires the exact id.`, + summary: options.summary ?? `Confirm ${options.resourceName} removal`, + why: + options.why ?? + `Removing this ${options.resourceName} is destructive and requires the exact id.`, fix: `Rerun with --confirm ${options.id}.`, exitCode: 2, nextSteps: [ - `prisma-cli ${options.commandName} ${options.id} --confirm ${options.id}`, + options.nextStep ?? + `prisma-cli ${options.commandName} ${options.id} --confirm ${options.id}`, ], meta: { expectedConfirm: options.id, @@ -624,6 +1058,28 @@ function databaseAmbiguousError( }); } +function backupNotFoundError( + backupId: string, + sourceDatabaseId: string, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + const listCommand = formatCommand([ + "database", + "backup", + "list", + sourceDatabaseId, + ]); + return new CliError({ + code: "DATABASE_BACKUP_NOT_FOUND", + domain: "database", + summary: "Database backup not found", + why: `No backup matched "${backupId}" for database "${sourceDatabaseId}".`, + fix: `Pass a backup id from ${listCommand}.`, + exitCode: 1, + nextSteps: [listCommand], + }); +} + function connectionNotFoundError(connectionId: string): CliError { return new CliError({ code: "DATABASE_CONNECTION_NOT_FOUND", diff --git a/packages/cli/src/lib/database/provider.ts b/packages/cli/src/lib/database/provider.ts index 4734f34d..39cebd23 100644 --- a/packages/cli/src/lib/database/provider.ts +++ b/packages/cli/src/lib/database/provider.ts @@ -1,11 +1,13 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Database pagination requests must run sequentially. import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { formatPrismaCliCommand } from "../../shell/cli-command"; import { CliError } from "../../shell/errors"; import type { DatabaseConnectionSummary, DatabaseSummary, } from "../../types/database"; +import type { PrismaCliPackageCommandFormatter } from "../agent/cli-command"; export interface DatabaseCreateInput { projectId: string; @@ -32,6 +34,46 @@ export interface DatabaseConnectionCreateRecord { connectionString: string; } +export interface DatabaseUsageRecord { + period: { + start: string; + end: string; + }; + metrics: { + operations: { used: number; unit: string }; + storage: { used: number; unit: string }; + }; + generatedAt: string; +} + +export interface DatabaseBackupRecord { + id: string; + backupType: string; + status: string; + size: number | null; + createdAt: string; +} + +export interface DatabaseBackupListRecord { + backups: DatabaseBackupRecord[]; + retentionDays: number | null; + hasMore: boolean; +} + +export interface DatabaseRestoreInput { + targetDatabaseId: string; + sourceDatabaseId: string; + backupId: string; + projectId: string; + signal?: AbortSignal; +} + +export interface DatabaseConnectionRotateRecord { + connection: DatabaseConnectionSummary; + database: { id: string; name: string } | null; + connectionString: string; +} + export interface DatabaseProvider { listDatabases(options: { projectId: string; @@ -61,6 +103,26 @@ export interface DatabaseProvider { connectionId: string, options?: { signal?: AbortSignal }, ): Promise; + getUsage( + databaseId: string, + options?: { + from?: string; + to?: string; + signal?: AbortSignal; + }, + ): Promise; + listBackups( + databaseId: string, + options?: { + limit?: number; + signal?: AbortSignal; + }, + ): Promise; + restoreDatabase(options: DatabaseRestoreInput): Promise; + rotateConnection( + connectionId: string, + options?: { signal?: AbortSignal }, + ): Promise; } interface RawApiErrorBody { @@ -97,6 +159,36 @@ interface RawDatabaseConnectionRecord { direct?: RawConnectionEndpoint | null; accelerate?: RawConnectionEndpoint | null; } | null; + database?: { + id?: string | null; + name?: string | null; + } | null; +} + +interface RawDatabaseUsageRecord { + period?: { + start?: string | null; + end?: string | null; + } | null; + metrics?: { + operations?: { used?: number | null; unit?: string | null } | null; + storage?: { used?: number | null; unit?: string | null } | null; + } | null; + generatedAt?: string | null; +} + +interface RawDatabaseBackupRecord { + id: string; + backupType?: string | null; + status?: string | null; + size?: number | null; + createdAt?: string | null; +} + +interface RawDatabaseBackupListBody { + data?: RawDatabaseBackupRecord[] | null; + meta?: { backupRetentionDays?: number | null } | null; + pagination?: { hasMore?: boolean | null } | null; } interface RawDatabaseRecord { @@ -117,7 +209,11 @@ interface RawDatabaseRecord { export function createManagementDatabaseProvider( client: ManagementApiClient, + options?: { formatCommand?: PrismaCliPackageCommandFormatter }, ): DatabaseProvider { + const formatCommand = + options?.formatCommand ?? ((args) => formatPrismaCliCommand(args)); + return { async listDatabases(options) { const databases: RawDatabaseRecord[] = []; @@ -290,6 +386,115 @@ export function createManagementDatabaseProvider( ); } }, + + async getUsage(databaseId, options) { + const result = await client.GET("/v1/databases/{databaseId}/usage", { + params: { + path: { databaseId }, + query: { + ...(options?.from ? { startDate: options.from } : {}), + ...(options?.to ? { endDate: options.to } : {}), + }, + }, + signal: options?.signal, + }); + if (result.error || !result.data) { + throw databaseApiError( + "Failed to fetch database usage", + result.response, + result.error, + ); + } + + return normalizeUsage(result.data as RawDatabaseUsageRecord); + }, + + async listBackups(databaseId, options) { + const result = await client.GET("/v1/databases/{databaseId}/backups", { + params: { + path: { databaseId }, + query: { + ...(options?.limit !== undefined ? { limit: options.limit } : {}), + }, + }, + signal: options?.signal, + }); + if (result.response?.status === 422) { + throw backupsUnsupportedError(databaseId, result.error); + } + if (result.error || !result.data) { + throw databaseApiError( + "Failed to list database backups", + result.response, + result.error, + ); + } + + return normalizeBackupList(result.data as RawDatabaseBackupListBody); + }, + + async restoreDatabase(options) { + const result = await client.POST( + "/v1/databases/{targetDatabaseId}/restore", + { + params: { + path: { targetDatabaseId: options.targetDatabaseId }, + }, + body: { + source: { + type: "backup", + databaseId: options.sourceDatabaseId, + backupId: options.backupId, + }, + }, + signal: options.signal, + }, + ); + if (result.response?.status === 409) { + throw restoreConflictError( + options.targetDatabaseId, + result.error, + formatCommand, + ); + } + // Target and source databases are resolved before this call, so a 404 + // here identifies the backup. + if (result.response?.status === 404) { + throw restoreBackupNotFoundError(options, result.error, formatCommand); + } + if (result.error || !result.data) { + throw databaseApiError( + "Failed to restore database", + result.response, + result.error, + ); + } + + return normalizeDatabase( + result.data.data as RawDatabaseRecord, + options.projectId, + ); + }, + + async rotateConnection(connectionId, options) { + const result = await client.POST("/v1/connections/{id}/rotate", { + params: { + path: { id: connectionId }, + }, + signal: options?.signal, + }); + if (result.error || !result.data) { + throw databaseApiError( + "Failed to rotate database connection", + result.response, + result.error, + ); + } + + return normalizeRotatedConnection( + result.data.data as RawDatabaseConnectionRecord, + ); + }, }; } @@ -414,6 +619,134 @@ function extractConnectionString( ); } +export function normalizeUsage( + usage: RawDatabaseUsageRecord, +): DatabaseUsageRecord { + return { + period: { + start: usage.period?.start ?? "", + end: usage.period?.end ?? "", + }, + metrics: { + operations: { + used: usage.metrics?.operations?.used ?? 0, + unit: usage.metrics?.operations?.unit ?? "ops", + }, + storage: { + used: usage.metrics?.storage?.used ?? 0, + unit: usage.metrics?.storage?.unit ?? "GiB", + }, + }, + generatedAt: usage.generatedAt ?? "", + }; +} + +export function normalizeBackupList( + body: RawDatabaseBackupListBody, +): DatabaseBackupListRecord { + return { + backups: (body.data ?? []).map((backup) => ({ + id: backup.id, + backupType: backup.backupType ?? "unknown", + status: backup.status ?? "unknown", + size: backup.size ?? null, + createdAt: backup.createdAt ?? "", + })), + retentionDays: body.meta?.backupRetentionDays ?? null, + hasMore: body.pagination?.hasMore ?? false, + }; +} + +export function normalizeRotatedConnection( + connection: RawDatabaseConnectionRecord, +): DatabaseConnectionRotateRecord { + const connectionString = extractConnectionString(connection); + if (!connectionString) { + throw new CliError({ + code: "DATABASE_CONNECTION_STRING_MISSING", + domain: "database", + summary: "Rotated connection did not return a connection string", + why: "Rotated connection strings are one-time-view secrets, but the Management API did not include one in this rotate response.", + fix: "Re-run the rotation, or create a replacement connection and store the returned URL immediately.", + exitCode: 1, + nextSteps: [], + }); + } + + const database = + connection.database?.id && connection.database?.name + ? { id: connection.database.id, name: connection.database.name } + : null; + + return { + connection: normalizeConnection( + connection, + connection.database?.id ?? connection.databaseId ?? "", + ), + database, + connectionString, + }; +} + +function backupsUnsupportedError( + databaseId: string, + error: RawApiErrorBody | undefined, +): CliError { + return new CliError({ + code: "DATABASE_BACKUPS_UNSUPPORTED", + domain: "database", + summary: "Backups are not available for this database", + why: + error?.error?.message ?? + `The platform does not manage backups for database "${databaseId}", for example because it is a remote/BYO database.`, + fix: "Use your own backup tooling for externally managed databases.", + exitCode: 1, + nextSteps: [], + }); +} + +function restoreBackupNotFoundError( + options: { backupId: string; sourceDatabaseId: string }, + error: RawApiErrorBody | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + const listCommand = formatCommand([ + "database", + "backup", + "list", + options.sourceDatabaseId, + ]); + return new CliError({ + code: "DATABASE_BACKUP_NOT_FOUND", + domain: "database", + summary: "Database backup not found", + why: + error?.error?.message ?? + `No backup matched "${options.backupId}" for database "${options.sourceDatabaseId}".`, + fix: `Pass a backup id from ${listCommand}.`, + exitCode: 1, + nextSteps: [listCommand], + }); +} + +function restoreConflictError( + targetDatabaseId: string, + error: RawApiErrorBody | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "DATABASE_RESTORE_CONFLICT", + domain: "database", + summary: "Database cannot be restored right now", + why: + error?.error?.message ?? + `Database "${targetDatabaseId}" is provisioning or already recovering.`, + fix: "Wait for the database to become ready, then retry the restore.", + exitCode: 1, + nextSteps: [formatCommand(["database", "show", targetDatabaseId])], + }); +} + function databaseApiError( summary: string, response: Response | undefined, diff --git a/packages/cli/src/presenters/database.ts b/packages/cli/src/presenters/database.ts index 025d051a..69ecab2a 100644 --- a/packages/cli/src/presenters/database.ts +++ b/packages/cli/src/presenters/database.ts @@ -4,14 +4,18 @@ import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import { formatColumns, renderSummaryLine } from "../shell/ui"; import type { + DatabaseBackupListResult, DatabaseConnectionCreateResult, DatabaseConnectionListResult, DatabaseConnectionRemoveResult, + DatabaseConnectionRotateResult, DatabaseCreateResult, DatabaseListResult, DatabaseRemoveResult, + DatabaseRestoreResult, DatabaseShowResult, DatabaseSummary, + DatabaseUsageResult, } from "../types/database"; import { renderResolvedProjectContextBlock, @@ -330,6 +334,262 @@ export function serializeDatabaseConnectionRemove( return { connection: result.connection }; } +export function renderDatabaseUsage( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseUsageResult, +): string[] { + const lines = renderShow( + { + title: "Showing database usage metrics.", + descriptor, + fields: [ + { key: "project", value: result.projectName }, + { key: "database", value: result.database.name }, + { key: "id", value: result.database.id, tone: "dim" }, + { + key: "period", + value: `${formatUsageTimestamp(result.period.start)} to ${formatUsageTimestamp(result.period.end)}`, + }, + { + key: "operations", + value: `${result.metrics.operations.used} ${result.metrics.operations.unit}`, + }, + { + key: "storage", + value: `${result.metrics.storage.used} ${result.metrics.storage.unit}`, + }, + { + key: "generated", + value: formatUsageTimestamp(result.generatedAt), + tone: "dim", + }, + ], + }, + context.ui, + ); + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; +} + +export function serializeDatabaseUsage(result: DatabaseUsageResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseBackupList( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseBackupListResult, +): string[] { + const ui = context.ui; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing platform-created database backups.")}`, + "", + ]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("database:")} ${result.database.name}`); + if (result.retentionDays !== null) { + lines.push( + `${rail} ${ui.accent("retention:")} ${result.retentionDays} days`, + ); + } + lines.push(rail); + + if (result.backups.length === 0) { + lines.push(`${rail} ${ui.dim("No backups found.")}`); + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; + } + + const rows = result.backups.map((backup) => [ + backup.id, + backup.backupType, + backup.status, + formatBackupSize(backup.size), + backup.createdAt, + ]); + const widths = [ + Math.max("Id".length, ...rows.map((row) => row[0].length)), + Math.max("Type".length, ...rows.map((row) => row[1].length)), + Math.max("Status".length, ...rows.map((row) => row[2].length)), + Math.max("Size".length, ...rows.map((row) => row[3].length)), + Math.max("Created".length, ...rows.map((row) => row[4].length)), + ]; + + lines.push( + `${rail} ${ui.accent(formatColumns(["Id", "Type", "Status", "Size", "Created"], widths))}`, + ); + for (const row of rows) { + lines.push(`${rail} ${formatColumns(row, widths)}`); + } + + if (result.hasMore) { + lines.push(rail); + lines.push( + `${rail} ${ui.dim("More backups exist; raise --limit to see them.")}`, + ); + } + + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; +} + +export function serializeDatabaseBackupList(result: DatabaseBackupListResult) { + return { + ...serializeList({ + context: { + project: result.projectName, + database: result.database.name, + }, + items: result.backups.map((backup) => ({ + noun: "backup", + label: backup.id, + id: backup.id, + status: null, + })), + }), + projectId: result.projectId, + database: result.database, + backups: result.backups, + retentionDays: result.retentionDays, + hasMore: result.hasMore, + }; +} + +export function renderDatabaseRestore( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseRestoreResult, +): string[] { + const lines = renderMutate( + { + title: "Restoring database from backup.", + descriptor, + context: [ + { key: "project", value: result.projectName }, + { key: "database", value: result.database.name }, + { key: "id", value: result.database.id, tone: "dim" }, + { key: "backup", value: result.source.backupId }, + ...(result.source.databaseId !== result.database.id + ? [{ key: "source", value: result.source.databaseId }] + : []), + ], + operationDescription: "Restoring database", + operationCount: 1, + details: [ + `The restore is running; the database status is "${result.database.status ?? "recovering"}" until it completes.`, + "Connections and credentials are preserved.", + ], + }, + context.ui, + ); + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; +} + +export function serializeDatabaseRestore(result: DatabaseRestoreResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseConnectionRotateStdout( + _context: CommandContext, + _descriptor: CommandDescriptor, + result: DatabaseConnectionRotateResult, +): string[] { + return [result.connectionString]; +} + +export function renderDatabaseConnectionRotate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: DatabaseConnectionRotateResult, +): string[] { + const ui = context.ui; + const target = result.database + ? `"${result.database.name}"` + : `connection ${result.connection.id}`; + const lines = [ + "Rotating connection...", + renderSummaryLine( + ui, + "success", + `Rotated credentials for ${target}. The previous credentials no longer work.`, + ), + " The connection URL below is shown once, so save it now.", + ]; + + if (ui.verbose) { + lines.push(""); + lines.push(...renderDatabaseConnectionRotateVerboseRows(context, result)); + } + + return lines; +} + +export function serializeDatabaseConnectionRotate( + result: DatabaseConnectionRotateResult, +) { + return result; +} + +function renderDatabaseConnectionRotateVerboseRows( + context: CommandContext, + result: DatabaseConnectionRotateResult, +): string[] { + const rows = [ + ...(result.database + ? [ + [ + "database", + formatResourceWithId( + context, + result.database.name, + result.database.id, + ), + ], + ] + : []), + [ + "connection", + formatResourceWithId( + context, + result.connection.name, + result.connection.id, + ), + ], + ]; + + return renderMetadataRows(rows); +} + +function formatBackupSize(size: number | null): string { + if (size === null) { + return "unknown"; + } + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KiB`; + } + if (size < 1024 * 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MiB`; + } + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GiB`; +} + +function formatUsageTimestamp(value: string): string { + return value || "unknown"; +} + function formatStatus(database: DatabaseSummary): string { return database.status ?? (database.isDefault ? "default" : "unknown"); } diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index af091de4..4db1f4d4 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -296,12 +296,44 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli database create my-db --branch feature/foo --region eu-central-1", ], }, + { + id: "database.usage", + path: ["prisma", "database", "usage"], + description: "Show usage metrics for a database", + examples: [ + "prisma-cli database usage db_123", + "prisma-cli database usage acme-production --from 2026-06-01 --to 2026-06-30", + ], + }, + { + id: "database.restore", + path: ["prisma", "database", "restore"], + description: "Restore a database from a backup after exact id confirmation", + examples: [ + "prisma-cli database restore db_123 --backup bkp_456 --confirm db_123", + ], + }, { id: "database.remove", path: ["prisma", "database", "remove"], description: "Remove a database after exact id confirmation", examples: ["prisma-cli database remove db_123 --confirm db_123"], }, + { + id: "database.backup", + path: ["prisma", "database", "backup"], + description: "Inspect platform-created database backups", + examples: ["prisma-cli database backup list db_123"], + }, + { + id: "database.backup.list", + path: ["prisma", "database", "backup", "list"], + description: "List backups for a database", + examples: [ + "prisma-cli database backup list db_123", + "prisma-cli database backup list acme-production --limit 50", + ], + }, { id: "database.connection", path: ["prisma", "database", "connection"], @@ -331,6 +363,15 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli database connection create db_123 --name readonly", ], }, + { + id: "database.connection.rotate", + path: ["prisma", "database", "connection", "rotate"], + description: + "Rotate connection credentials and print the new one-time connection URL", + examples: [ + "prisma-cli database connection rotate conn_123 --confirm conn_123", + ], + }, { id: "database.connection.remove", path: ["prisma", "database", "connection", "remove"], diff --git a/packages/cli/src/types/database.ts b/packages/cli/src/types/database.ts index 96023b11..deee87a4 100644 --- a/packages/cli/src/types/database.ts +++ b/packages/cli/src/types/database.ts @@ -80,3 +80,66 @@ export interface DatabaseConnectionRemoveResult { id: string; }; } + +export interface DatabaseUsagePeriod { + start: string; + end: string; +} + +export interface DatabaseUsageMetric { + used: number; + unit: string; +} + +export interface DatabaseUsageMetrics { + operations: DatabaseUsageMetric; + storage: DatabaseUsageMetric; +} + +export interface DatabaseUsageResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + period: DatabaseUsagePeriod; + metrics: DatabaseUsageMetrics; + generatedAt: string; +} + +export interface DatabaseBackupSummary { + id: string; + backupType: string; + status: string; + size: number | null; + createdAt: string; +} + +export interface DatabaseBackupListResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + backups: DatabaseBackupSummary[]; + retentionDays: number | null; + hasMore: boolean; +} + +export interface DatabaseRestoreResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + source: { + databaseId: string; + backupId: string; + }; +} + +export interface DatabaseConnectionRotateResult { + connection: DatabaseConnectionSummary; + database: { + id: string; + name: string; + } | null; + connectionString: string; +} diff --git a/packages/cli/tests/database.test.ts b/packages/cli/tests/database.test.ts index 49798d99..8564e72a 100644 --- a/packages/cli/tests/database.test.ts +++ b/packages/cli/tests/database.test.ts @@ -67,12 +67,16 @@ describe("database commands", () => { expect(databaseHelp).toMatchInlineSnapshot(` "database → Manage Prisma Postgres databases for a project - │ list List Prisma Postgres databases for the resolved project - │ show Show database metadata without secret values - │ create Create a Prisma Postgres database and print its one-time - │ connection URL - │ remove Remove a database after exact id confirmation - │ connection Manage one-time-view database connection strings + │ list List Prisma Postgres databases for the resolved project + │ show Show database metadata without secret values + │ create Create a Prisma Postgres database and print its + │ one-time connection URL + │ usage Show usage metrics for a database + │ restore Restore a database from a backup after exact id + │ confirmation + │ remove Remove a database after exact id confirmation + │ backup Inspect platform-created database backups + │ connection Manage one-time-view database connection strings │ │ Global options: │ --json Emit structured JSON output. @@ -103,6 +107,8 @@ describe("database commands", () => { │ values │ create Create a database connection and print its one-time │ connection URL + │ rotate Rotate connection credentials and print the new + │ one-time connection URL │ remove Remove a database connection after exact id │ confirmation │ @@ -488,6 +494,543 @@ describe("database commands", () => { }); }); + it("shows database usage metrics with the standard JSON envelope", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "usage", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "database.usage", + result: { + database: { + id: "db_123", + name: "acme-preview", + }, + period: { + start: "2026-06-01T00:00:00.000Z", + end: "2026-06-30T23:59:59.999Z", + }, + metrics: { + operations: { used: 12500, unit: "ops" }, + storage: { used: 1.25, unit: "GiB" }, + }, + generatedAt: "2026-07-01T00:00:00.000Z", + }, + }); + expect(JSON.stringify(payload)).not.toContain("connectionString"); + }); + + it("renders database usage in human output", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "usage", "acme-preview"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(stderr).toContain("operations:"); + expect(stderr).toContain("12500 ops"); + expect(stderr).toContain("storage:"); + expect(stderr).toContain("1.25 GiB"); + }); + + it("normalizes calendar dates and accepts ISO datetimes for usage periods", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const dateOnly = await executeCli({ + argv: [ + "database", + "usage", + "db_123", + "--from", + "2026-06-01", + "--to", + "2026-06-30", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const dateOnlyPayload = JSON.parse(dateOnly.stdout); + + expect(dateOnly.exitCode).toBe(0); + expect(dateOnlyPayload.result.period).toMatchObject({ + start: "2026-06-01T00:00:00.000Z", + // A calendar-date --to expands to the END of its UTC day so the range + // is calendar-day inclusive. + end: "2026-06-30T23:59:59.999Z", + }); + + const sameDay = await executeCli({ + argv: [ + "database", + "usage", + "db_123", + "--from", + "2026-06-15", + "--to", + "2026-06-15", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const sameDayPayload = JSON.parse(sameDay.stdout); + + expect(sameDay.exitCode).toBe(0); + expect(sameDayPayload.result.period).toMatchObject({ + start: "2026-06-15T00:00:00.000Z", + end: "2026-06-15T23:59:59.999Z", + }); + + const dateTime = await executeCli({ + argv: [ + "database", + "usage", + "db_123", + "--from", + "2026-06-01T12:00:00Z", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const dateTimePayload = JSON.parse(dateTime.stdout); + + expect(dateTime.exitCode).toBe(0); + expect(dateTimePayload.result.period.start).toBe("2026-06-01T12:00:00Z"); + }); + + it("rejects impossible calendar dates for usage periods", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + for (const from of ["2026-02-30", "2026-02-30T10:00:00Z"]) { + const result = await executeCli({ + argv: ["database", "usage", "db_123", "--from", from, "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "database.usage", + error: { code: "USAGE_ERROR" }, + }); + } + }); + + it("rejects an invalid usage period before calling the API", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const badDate = await executeCli({ + argv: ["database", "usage", "db_123", "--from", "not-a-date", "--json"], + cwd, + stateDir, + fixturePath, + }); + const badDatePayload = JSON.parse(badDate.stdout); + + expect(badDate.exitCode).toBe(2); + expect(badDatePayload).toMatchObject({ + ok: false, + command: "database.usage", + error: { code: "USAGE_ERROR" }, + }); + + const inverted = await executeCli({ + argv: [ + "database", + "usage", + "db_123", + "--from", + "2026-06-30", + "--to", + "2026-06-01", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const invertedPayload = JSON.parse(inverted.stdout); + + expect(inverted.exitCode).toBe(2); + expect(invertedPayload).toMatchObject({ + ok: false, + command: "database.usage", + error: { code: "USAGE_ERROR" }, + }); + }); + + it("lists database backups with retention metadata and without secrets", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "backup", "list", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "database.backup.list", + result: { + database: { id: "db_123" }, + backups: [ + { + id: "bkp_101", + backupType: "full", + status: "completed", + size: 1048576, + }, + { + id: "bkp_102", + backupType: "incremental", + status: "completed", + size: null, + }, + ], + retentionDays: 35, + hasMore: false, + count: 2, + }, + }); + expect(JSON.stringify(payload)).not.toContain("connectionString"); + }); + + it("caps database backups with --limit and reports hasMore", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "backup", "list", "db_123", "--limit", "1", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.backups).toHaveLength(1); + expect(payload.result.hasMore).toBe(true); + + const invalid = await executeCli({ + argv: [ + "database", + "backup", + "list", + "db_123", + "--limit", + "500", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const invalidPayload = JSON.parse(invalid.stdout); + + expect(invalid.exitCode).toBe(2); + expect(invalidPayload).toMatchObject({ + ok: false, + command: "database.backup.list", + error: { code: "USAGE_ERROR" }, + }); + }); + + it("requires exact target database id confirmation before restore", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const wrongConfirm = await executeCli({ + argv: [ + "database", + "restore", + "acme-preview", + "--backup", + "bkp_101", + "--confirm", + "acme-preview", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const wrongPayload = JSON.parse(wrongConfirm.stdout); + + expect(wrongConfirm.exitCode).toBe(2); + expect(wrongPayload).toMatchObject({ + ok: false, + command: "database.restore", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "database", + meta: { + expectedConfirm: "db_123", + receivedConfirm: "acme-preview", + }, + }, + }); + + const exactConfirm = await executeCli({ + argv: [ + "database", + "restore", + "acme-preview", + "--backup", + "bkp_101", + "--confirm", + "db_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const exactPayload = JSON.parse(exactConfirm.stdout); + + expect(exactConfirm.exitCode).toBe(0); + expect(exactPayload).toMatchObject({ + ok: true, + command: "database.restore", + result: { + database: { + id: "db_123", + status: "recovering", + }, + source: { + databaseId: "db_123", + backupId: "bkp_101", + }, + }, + nextSteps: ["npx -y @prisma/cli@latest database show db_123"], + }); + }); + + it("requires --backup for restore and fails on unknown backups", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const missingBackup = await executeCli({ + argv: ["database", "restore", "db_123", "--confirm", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const missingPayload = JSON.parse(missingBackup.stdout); + + expect(missingBackup.exitCode).toBe(2); + expect(missingPayload).toMatchObject({ + ok: false, + command: "database.restore", + error: { code: "USAGE_ERROR" }, + }); + + const unknownBackup = await executeCli({ + argv: [ + "database", + "restore", + "db_123", + "--backup", + "bkp_999", + "--confirm", + "db_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const unknownPayload = JSON.parse(unknownBackup.stdout); + + expect(unknownBackup.exitCode).toBe(1); + expect(unknownPayload).toMatchObject({ + ok: false, + command: "database.restore", + error: { code: "DATABASE_BACKUP_NOT_FOUND" }, + }); + }); + + it("keeps --source-database in the confirmation recovery command", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "database", + "restore", + "acme-preview", + "--backup", + "bkp_201", + "--source-database", + "acme-production", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("CONFIRMATION_REQUIRED"); + expect(payload.nextSteps).toEqual([ + "npx -y @prisma/cli@latest database restore db_123 --backup bkp_201 --source-database db_456 --confirm db_123", + ]); + }); + + it("restores from another database's backup with --source-database", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "database", + "restore", + "acme-preview", + "--backup", + "bkp_201", + "--source-database", + "acme-production", + "--confirm", + "db_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "database.restore", + result: { + database: { id: "db_123" }, + source: { + databaseId: "db_456", + backupId: "bkp_201", + }, + }, + }); + }); + + it("requires exact connection id confirmation before rotation and prints the URL once", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const wrongConfirm = await executeCli({ + argv: [ + "database", + "connection", + "rotate", + "conn_123", + "--confirm", + "primary", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const wrongPayload = JSON.parse(wrongConfirm.stdout); + + expect(wrongConfirm.exitCode).toBe(2); + expect(wrongPayload).toMatchObject({ + ok: false, + command: "database.connection.rotate", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "database", + meta: { + expectedConfirm: "conn_123", + receivedConfirm: "primary", + }, + }, + }); + + const rotated = await executeCli({ + argv: [ + "database", + "connection", + "rotate", + "conn_123", + "--confirm", + "conn_123", + ], + cwd, + stateDir, + fixturePath, + }); + + expect(rotated.exitCode).toBe(0); + expect(stripAnsi(rotated.stderr)).toBe( + "Rotating connection...\n" + + '✔ Rotated credentials for "acme-preview". The previous credentials no longer work.\n' + + " The connection URL below is shown once, so save it now.\n" + + "\n", + ); + expect(rotated.stdout).toBe( + "postgresql://rotated-db_123-conn_123.example.prisma.io/postgres\n", + ); + expect( + `${rotated.stdout}${rotated.stderr}`.split("postgresql://rotated-"), + ).toHaveLength(2); + }); + + it("returns the rotated one-time connection URL once in JSON", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "database", + "connection", + "rotate", + "conn_123", + "--confirm", + "conn_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "database.connection.rotate", + result: { + connection: { id: "conn_123" }, + database: { id: "db_123", name: "acme-preview" }, + connectionString: + "postgresql://rotated-db_123-conn_123.example.prisma.io/postgres", + }, + }); + expect(JSON.stringify(payload).split("postgresql://rotated-")).toHaveLength( + 2, + ); + }); + it("requires exact connection id confirmation before removal", async () => { const { cwd, stateDir } = await setupLinkedProject();