Skip to content

Commit 8b86215

Browse files
fix(cli): address review feedback on database usage and restore
- Validate --from/--to strictly: accept YYYY-MM-DD (expanded to midnight UTC, matching the API's ISO datetime contract) or a full ISO datetime, and reject rollover dates like 2026-02-30 that Date.parse silently accepts - Keep --source-database in the CONFIRMATION_REQUIRED recovery command for cross-database restores - Document DATABASE_BACKUP_NOT_FOUND and the date normalization rules in the command spec
1 parent 51d96b2 commit 8b86215

3 files changed

Lines changed: 130 additions & 13 deletions

File tree

docs/product/command-spec.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,8 @@ Behavior:
873873
- resolves `<database>` by exact database id or exact database name inside the resolved project
874874
- `--branch <git-name>` narrows name resolution when the same database name exists on multiple Branches
875875
- `--from <iso-date>` and `--to <iso-date>` bound the reporting period; when omitted, the platform defaults the period to the current month so far
876-
- the CLI validates that `--from` and `--to` parse as dates and that `--from` is not after `--to` before calling the API
876+
- `--from` and `--to` accept a calendar date (`2026-06-01`) or an ISO datetime (`2026-06-01T12:00:00Z`); calendar dates are expanded to midnight UTC before calling the API, and the platform treats the end bound as inclusive of its day
877+
- the CLI rejects malformed or impossible calendar dates (for example `2026-02-30`) and a `--from` later than `--to` before calling the API
877878
- reports operations used (`ops`) and storage used (`GiB`) for the period, plus the resolved period bounds and the generation timestamp
878879
- read-only; never prints or returns connection strings, passwords, or endpoint secrets
879880
- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when the target cannot be selected safely
@@ -934,6 +935,7 @@ Behavior:
934935
- by default the backup must belong to the target database; `--source-database <database>` restores from another database's backup, for example a production backup into a scratch database, and requires access to both databases' projects
935936
- requires `--confirm <database-id>` where the value exactly matches the resolved target database id; `--yes` does not satisfy this confirmation
936937
- 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
938+
- fails with `DATABASE_BACKUP_NOT_FOUND` when the backup id cannot be resolved for the source database
937939
- fails with `DATABASE_RESTORE_CONFLICT` when the target database is provisioning or already recovering
938940
- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when a database target cannot be selected safely
939941
- never prints or returns connection strings, passwords, or endpoint secrets

packages/cli/src/controllers/database.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -489,14 +489,18 @@ export async function runDatabaseRestore(
489489
)
490490
: database;
491491

492+
const sourceDatabaseArg =
493+
sourceDatabase.id === database.id
494+
? ""
495+
: ` --source-database ${sourceDatabase.id}`;
492496
requireExactConfirmation({
493497
resourceName: "database",
494498
commandName: "database restore",
495499
id: database.id,
496500
confirm: flags.confirm,
497501
summary: "Confirm database restore",
498502
why: "Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database id.",
499-
nextStep: `prisma-cli database restore ${database.id} --backup ${backupId} --confirm ${database.id}`,
503+
nextStep: `prisma-cli database restore ${database.id} --backup ${backupId}${sourceDatabaseArg} --confirm ${database.id}`,
500504
});
501505

502506
const restored = await provider.restoreDatabase({
@@ -568,6 +572,9 @@ export async function runDatabaseConnectionRotate(
568572
};
569573
}
570574

575+
const USAGE_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
576+
const USAGE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
577+
571578
function parseUsageDate(
572579
value: string | undefined,
573580
flagName: string,
@@ -576,20 +583,37 @@ function parseUsageDate(
576583
return undefined;
577584
}
578585

586+
// The Management API validates startDate/endDate as full ISO datetimes, so
587+
// date-only input is expanded to midnight UTC. Date.parse alone is too
588+
// permissive: it rolls over invalid calendar dates such as 2026-02-30, so
589+
// the date part must round-trip through toISOString unchanged.
579590
const trimmed = value.trim();
580-
if (!trimmed || Number.isNaN(Date.parse(trimmed))) {
581-
throw usageError(
582-
"Invalid usage period",
583-
`${flagName} must be an ISO date such as 2026-06-01.`,
584-
`Pass an ISO date to ${flagName}.`,
585-
[
586-
"prisma-cli database usage <database> --from 2026-06-01 --to 2026-06-30",
587-
],
588-
"database",
589-
);
591+
if (USAGE_DATE_ONLY_PATTERN.test(trimmed) && isValidCalendarDate(trimmed)) {
592+
return `${trimmed}T00:00:00.000Z`;
593+
}
594+
if (
595+
USAGE_DATETIME_PATTERN.test(trimmed) &&
596+
!Number.isNaN(Date.parse(trimmed)) &&
597+
isValidCalendarDate(trimmed.slice(0, 10))
598+
) {
599+
return trimmed;
590600
}
591601

592-
return trimmed;
602+
throw usageError(
603+
"Invalid usage period",
604+
`${flagName} must be an ISO date such as 2026-06-01 or an ISO datetime such as 2026-06-01T12:00:00Z.`,
605+
`Pass an ISO date or datetime to ${flagName}.`,
606+
["prisma-cli database usage <database> --from 2026-06-01 --to 2026-06-30"],
607+
"database",
608+
);
609+
}
610+
611+
function isValidCalendarDate(datePart: string): boolean {
612+
const timestamp = Date.parse(`${datePart}T00:00:00.000Z`);
613+
return (
614+
!Number.isNaN(timestamp) &&
615+
new Date(timestamp).toISOString().startsWith(datePart)
616+
);
593617
}
594618

595619
function parseBackupLimit(value: string | undefined): number | undefined {

packages/cli/tests/database.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,70 @@ describe("database commands", () => {
546546
expect(stderr).toContain("1.25 GiB");
547547
});
548548

549+
it("normalizes calendar dates and accepts ISO datetimes for usage periods", async () => {
550+
const { cwd, stateDir } = await setupLinkedProject();
551+
552+
const dateOnly = await executeCli({
553+
argv: [
554+
"database",
555+
"usage",
556+
"db_123",
557+
"--from",
558+
"2026-06-01",
559+
"--to",
560+
"2026-06-30",
561+
"--json",
562+
],
563+
cwd,
564+
stateDir,
565+
fixturePath,
566+
});
567+
const dateOnlyPayload = JSON.parse(dateOnly.stdout);
568+
569+
expect(dateOnly.exitCode).toBe(0);
570+
expect(dateOnlyPayload.result.period).toMatchObject({
571+
start: "2026-06-01T00:00:00.000Z",
572+
end: "2026-06-30T00:00:00.000Z",
573+
});
574+
575+
const dateTime = await executeCli({
576+
argv: [
577+
"database",
578+
"usage",
579+
"db_123",
580+
"--from",
581+
"2026-06-01T12:00:00Z",
582+
"--json",
583+
],
584+
cwd,
585+
stateDir,
586+
fixturePath,
587+
});
588+
const dateTimePayload = JSON.parse(dateTime.stdout);
589+
590+
expect(dateTime.exitCode).toBe(0);
591+
expect(dateTimePayload.result.period.start).toBe("2026-06-01T12:00:00Z");
592+
});
593+
594+
it("rejects impossible calendar dates for usage periods", async () => {
595+
const { cwd, stateDir } = await setupLinkedProject();
596+
597+
const result = await executeCli({
598+
argv: ["database", "usage", "db_123", "--from", "2026-02-30", "--json"],
599+
cwd,
600+
stateDir,
601+
fixturePath,
602+
});
603+
const payload = JSON.parse(result.stdout);
604+
605+
expect(result.exitCode).toBe(2);
606+
expect(payload).toMatchObject({
607+
ok: false,
608+
command: "database.usage",
609+
error: { code: "USAGE_ERROR" },
610+
});
611+
});
612+
549613
it("rejects an invalid usage period before calling the API", async () => {
550614
const { cwd, stateDir } = await setupLinkedProject();
551615

@@ -779,6 +843,33 @@ describe("database commands", () => {
779843
});
780844
});
781845

846+
it("keeps --source-database in the confirmation recovery command", async () => {
847+
const { cwd, stateDir } = await setupLinkedProject();
848+
849+
const result = await executeCli({
850+
argv: [
851+
"database",
852+
"restore",
853+
"acme-preview",
854+
"--backup",
855+
"bkp_201",
856+
"--source-database",
857+
"acme-production",
858+
"--json",
859+
],
860+
cwd,
861+
stateDir,
862+
fixturePath,
863+
});
864+
const payload = JSON.parse(result.stdout);
865+
866+
expect(result.exitCode).toBe(2);
867+
expect(payload.error.code).toBe("CONFIRMATION_REQUIRED");
868+
expect(payload.nextSteps).toEqual([
869+
"prisma-cli database restore db_123 --backup bkp_201 --source-database db_456 --confirm db_123",
870+
]);
871+
});
872+
782873
it("restores from another database's backup with --source-database", async () => {
783874
const { cwd, stateDir } = await setupLinkedProject();
784875

0 commit comments

Comments
 (0)