From bd7853b192c839c30aedc968c51fc375ff5686c0 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Mon, 23 Feb 2026 22:05:57 -0500 Subject: [PATCH 1/8] feat(create-db): add ttl/copy/quiet/open flags with clearer ttl validation and cleaner help --- create-db-worker/src/delete-workflow.ts | 10 +- create-db-worker/src/index.ts | 16 ++- create-db/README.md | 21 ++++ create-db/__tests__/cli.test.ts | 4 + create-db/__tests__/flags.test.ts | 116 +++++++++++++++++++++ create-db/__tests__/ttl.test.ts | 25 +++++ create-db/package.json | 6 +- create-db/src/cli.ts | 38 ++++++- create-db/src/cli/commands/create.ts | 130 ++++++++++++++++++++++-- create-db/src/cli/flags.ts | 28 ++++- create-db/src/cli/output.ts | 73 +++++++++++++ create-db/src/core/database.ts | 30 ++++-- create-db/src/core/services.ts | 7 +- create-db/src/utils/ttl.ts | 10 ++ 14 files changed, 484 insertions(+), 30 deletions(-) create mode 100644 create-db/__tests__/ttl.test.ts create mode 100644 create-db/src/utils/ttl.ts diff --git a/create-db-worker/src/delete-workflow.ts b/create-db-worker/src/delete-workflow.ts index 8bbadc1..4ddcc62 100644 --- a/create-db-worker/src/delete-workflow.ts +++ b/create-db-worker/src/delete-workflow.ts @@ -2,6 +2,7 @@ import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:work type Params = { projectID: string; + ttlSeconds?: number; }; type Env = { @@ -10,13 +11,18 @@ type Env = { export class DeleteDbWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { - const { projectID } = event.payload; + const { projectID, ttlSeconds } = event.payload; if (!projectID) { throw new Error('No projectID provided.'); } - await step.sleep('wait 24 hours', '24 hours'); + const effectiveTtlSeconds = + typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0 + ? Math.floor(ttlSeconds) + : 24 * 60 * 60; + + await step.sleep(`wait ${effectiveTtlSeconds} seconds`, `${effectiveTtlSeconds} seconds`); const res = await fetch(`https://api.prisma.io/v1/projects/${projectID}`, { method: 'DELETE', diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index 72c5d8a..3724dce 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -132,6 +132,7 @@ export default { analytics?: { eventName?: string; properties?: Record }; userAgent?: string; source?: 'programmatic' | 'cli'; + ttlSeconds?: number; }; let body: CreateDbBody = {}; @@ -142,7 +143,16 @@ export default { return new Response('Invalid JSON body', { status: 400 }); } - const { region, name, analytics: analyticsData, userAgent, source } = body; + const { region, name, analytics: analyticsData, userAgent, source, ttlSeconds } = body; + + const parsedTtlSeconds = + typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) + ? Math.floor(ttlSeconds) + : undefined; + + if (ttlSeconds !== undefined && (!parsedTtlSeconds || parsedTtlSeconds <= 0)) { + return new Response('Invalid ttlSeconds in request body', { status: 400 }); + } // Apply stricter rate limiting for programmatic requests if (source === 'programmatic') { @@ -211,7 +221,9 @@ export default { const response = JSON.parse(prismaText); const projectID = response.data ? response.data.id : response.id; - const workflowPromise = env.DELETE_DB_WORKFLOW.create({ params: { projectID } }); + const workflowPromise = env.DELETE_DB_WORKFLOW.create({ + params: { projectID, ttlSeconds: parsedTtlSeconds }, + }); const analyticsPromise = env.CREATE_DB_DATASET.writeDataPoint({ blobs: ['database_created'], diff --git a/create-db/README.md b/create-db/README.md index a308958..ef94d5f 100644 --- a/create-db/README.md +++ b/create-db/README.md @@ -38,6 +38,10 @@ npx create-db regions # List available regions | `--interactive` | `-i` | Interactive mode to select a region | | `--json` | `-j` | Output machine-readable JSON | | `--env ` | `-e` | Write DATABASE_URL and CLAIM_URL to specified .env file | +| `--ttl ` | `-t` | Custom database TTL (`30m`, `1h` ... `24h`) | +| `--copy` | `-c` | Copy connection string to clipboard | +| `--quiet` | `-q` | Output only the connection string | +| `--open` | `-o` | Open claim URL in browser | | `--help` | `-h` | Show help message | | `--version` | | Show version | @@ -72,9 +76,26 @@ npx create-db -j npx create-db --env .env npx create-db -e .env.local +# Set custom TTL +npx create-db --ttl 1h +npx create-db -t 12h + +# Copy connection string to clipboard +npx create-db --copy +npx create-db -c + +# Only print connection string +npx create-db --quiet +npx create-db -q + +# Open claim URL in browser +npx create-db --open +npx create-db -o + # Combine flags npx create-db -r eu-central-1 -j npx create-db -i -e .env +npx create-db -t 24h -c -o # List available regions npx create-db regions diff --git a/create-db/__tests__/cli.test.ts b/create-db/__tests__/cli.test.ts index aa0e6b8..b6c417d 100644 --- a/create-db/__tests__/cli.test.ts +++ b/create-db/__tests__/cli.test.ts @@ -59,6 +59,10 @@ describe("CLI help and version", () => { expect(result.all).toContain("--interactive"); expect(result.all).toContain("--json"); expect(result.all).toContain("--env"); + expect(result.all).toContain("--ttl"); + expect(result.all).toContain("--copy"); + expect(result.all).toContain("--quiet"); + expect(result.all).toContain("--open"); }); it("displays regions command help", async () => { diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts index f133cff..179e6dd 100644 --- a/create-db/__tests__/flags.test.ts +++ b/create-db/__tests__/flags.test.ts @@ -112,6 +112,102 @@ describe("CreateFlags schema", () => { }); }); + describe("ttl field", () => { + it("accepts valid ttl strings", () => { + const validTtls = ["30m", "1h", "6h", "12h", "24h"]; + + for (const ttl of validTtls) { + const result = CreateFlags.safeParse({ ttl }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBe(ttl); + } + } + }); + + it("passes through ttl strings for command-level validation", () => { + const ttlInputs = ["25h", "7d", "45s", "one-hour", "24"]; + + for (const ttl of ttlInputs) { + const result = CreateFlags.safeParse({ ttl }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBe(ttl); + } + } + }); + + it("coerces boolean ttl to empty string when value is missing", () => { + const result = CreateFlags.safeParse({ ttl: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBe(""); + } + }); + + it("allows undefined", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBeUndefined(); + } + }); + }); + + describe("copy field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.copy).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ copy: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.copy).toBe(true); + } + }); + }); + + describe("quiet field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.quiet).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ quiet: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.quiet).toBe(true); + } + }); + }); + + describe("open field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.open).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ open: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.open).toBe(true); + } + }); + }); + describe("userAgent field", () => { it("accepts custom user agent string", () => { const result = CreateFlags.safeParse({ userAgent: "myapp/1.0.0" }); @@ -137,6 +233,10 @@ describe("CreateFlags schema", () => { interactive: true, json: false, env: ".env.local", + ttl: "12h", + copy: true, + quiet: false, + open: true, userAgent: "test/2.0.0", }; @@ -148,6 +248,10 @@ describe("CreateFlags schema", () => { interactive: true, json: false, env: ".env.local", + ttl: "12h", + copy: true, + quiet: false, + open: true, userAgent: "test/2.0.0", }); } @@ -161,6 +265,10 @@ describe("CreateFlags schema", () => { expect(result.data.interactive).toBe(false); expect(result.data.json).toBe(false); expect(result.data.env).toBeUndefined(); + expect(result.data.ttl).toBeUndefined(); + expect(result.data.copy).toBe(false); + expect(result.data.quiet).toBe(false); + expect(result.data.open).toBe(false); expect(result.data.userAgent).toBeUndefined(); } }); @@ -173,6 +281,10 @@ describe("CreateFlags schema", () => { interactive: false, json: true, env: ".env", + ttl: "24h", + copy: true, + quiet: false, + open: true, userAgent: "test/1.0", }; @@ -181,6 +293,10 @@ describe("CreateFlags schema", () => { expect(result.interactive).toBe(input.interactive); expect(result.json).toBe(input.json); expect(result.env).toBe(input.env); + expect(result.ttl).toBe(input.ttl); + expect(result.copy).toBe(input.copy); + expect(result.quiet).toBe(input.quiet); + expect(result.open).toBe(input.open); expect(result.userAgent).toBe(input.userAgent); }); }); diff --git a/create-db/__tests__/ttl.test.ts b/create-db/__tests__/ttl.test.ts new file mode 100644 index 0000000..0d3a858 --- /dev/null +++ b/create-db/__tests__/ttl.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { parseTtlToSeconds } from "../src/utils/ttl.js"; + +describe("parseTtlToSeconds", () => { + it("parses supported ttl values", () => { + expect(parseTtlToSeconds("30m")).toBe(1800); + expect(parseTtlToSeconds("1h")).toBe(3600); + expect(parseTtlToSeconds("6h")).toBe(21600); + expect(parseTtlToSeconds("24h")).toBe(86400); + }); + + it("is case-insensitive", () => { + expect(parseTtlToSeconds("2H")).toBe(7200); + expect(parseTtlToSeconds("24H")).toBe(86400); + }); + + it("rejects values outside the allowed range", () => { + expect(parseTtlToSeconds("")).toBeNull(); + expect(parseTtlToSeconds("0h")).toBeNull(); + expect(parseTtlToSeconds("25h")).toBeNull(); + expect(parseTtlToSeconds("7d")).toBeNull(); + expect(parseTtlToSeconds("45s")).toBeNull(); + expect(parseTtlToSeconds("abc")).toBeNull(); + }); +}); diff --git a/create-db/package.json b/create-db/package.json index b934eb2..c0f0e69 100644 --- a/create-db/package.json +++ b/create-db/package.json @@ -52,11 +52,11 @@ "@clack/prompts": "^0.11.0", "@orpc/server": "^1.12.2", "dotenv": "^17.2.3", + "execa": "^9.6.1", "picocolors": "^1.1.1", "terminal-link": "^5.0.0", - "trpc-cli": "^0.12.1", - "zod": "^4.1.13", - "execa": "^9.6.1" + "trpc-cli": "^0.12.4", + "zod": "^4.1.13" }, "publishConfig": { "access": "public" diff --git a/create-db/src/cli.ts b/create-db/src/cli.ts index 8c21feb..b47fb0f 100644 --- a/create-db/src/cli.ts +++ b/create-db/src/cli.ts @@ -1,3 +1,39 @@ import { createDbCli } from "./index.js"; -createDbCli().run(); +type OptionLike = { + flags?: string; + long?: string; +}; + +type CommandLike = { + options?: OptionLike[]; + commands?: CommandLike[]; +}; + +function simplifyHelpFlags(command: CommandLike) { + for (const option of command.options ?? []) { + if (!option.flags) { + continue; + } + + option.flags = option.flags + .replace(" [boolean]", "") + .replace(" [string]", " [value]") + .replace(" ", " "); + + if (option.long === "--ttl" || option.flags.includes("--ttl")) { + option.flags = option.flags + .replace("", "") + .replace("[value]", "[duration]"); + } + } + + for (const subcommand of command.commands ?? []) { + simplifyHelpFlags(subcommand); + } +} + +const cli = createDbCli(); +const program = cli.buildProgram() as unknown as CommandLike; +simplifyHelpFlags(program); +void cli.run(undefined, program as never); diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts index eae4e8b..1076dc9 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -2,10 +2,11 @@ import { select, isCancel } from "@clack/prompts"; import { randomUUID } from "crypto"; import type { CreateFlagsInput } from "../flags.js"; -import type { RegionId } from "../../types.js"; +import type { RegionId, DatabaseResult } from "../../types.js"; import { getCommandName } from "../../core/database.js"; import { readUserEnvFile } from "../../utils/env-utils.js"; import { detectUserLocation, getRegionClosestToLocation } from "../../utils/geolocation.js"; +import { parseTtlToSeconds } from "../../utils/ttl.js"; import { sendAnalyticsEvent, flushAnalytics, @@ -24,12 +25,78 @@ import { printError, printSuccess, writeEnvFile, + copyToClipboard, + openUrlInBrowser, } from "../output.js"; +const TTL_EXAMPLES = [ + "Examples:", + "npx create-db --ttl 24h", + "npx create-db --ttl 8h", + "npx create-db --ttl 1h", + "npx create-db --ttl 30m", +].join("\n"); + +function applyCopyFlag(result: DatabaseResult, quiet: boolean) { + if (!result.connectionString) { + printError("Connection string is unavailable, cannot copy to clipboard."); + process.exit(1); + } + + const copyResult = copyToClipboard(result.connectionString); + if (!copyResult.success) { + printError(`Failed to copy connection string: ${copyResult.error}`); + process.exit(1); + } + + if (!quiet) { + printSuccess("Copied connection string to clipboard"); + } +} + +function applyOpenFlag(result: DatabaseResult, quiet: boolean) { + const openResult = openUrlInBrowser(result.claimUrl); + if (!openResult.success) { + printError(`Failed to open claim URL: ${openResult.error}`); + process.exit(1); + } + + if (!quiet) { + printSuccess("Opened claim URL in browser"); + } +} + export async function handleCreate(input: CreateFlagsInput): Promise { const cliRunId = randomUUID(); const CLI_NAME = getCommandName(); + if (input.ttl === "") { + printError( + [ + "Could not create database: --ttl was provided without a value.", + "Allowed values are 30m or 1h-24h.", + "", + TTL_EXAMPLES, + ].join("\n") + ); + process.exit(1); + } + + const ttlSeconds = input.ttl ? parseTtlToSeconds(input.ttl) : null; + if (typeof input.ttl === "string" && ttlSeconds === null) { + printError( + [ + `Could not create database: --ttl value "${input.ttl}" is invalid.`, + "Allowed values are 30m or 1h-24h.", + "", + TTL_EXAMPLES, + ].join("\n") + ); + process.exit(1); + } + + const interactiveMode = input.interactive && !input.quiet; + let userAgent: string | undefined = input.userAgent; if (!userAgent) { const userEnvVars = readUserEnvFile(); @@ -46,6 +113,11 @@ export async function handleCreate(input: CreateFlagsInput): Promise { "has-interactive-flag": input.interactive, "has-json-flag": input.json, "has-env-flag": !!input.env, + "has-ttl-flag": !!input.ttl, + "has-copy-flag": input.copy, + "has-quiet-flag": input.quiet, + "has-open-flag": input.open, + "ttl-seconds": ttlSeconds ?? undefined, "user-agent": userAgent || undefined, "node-version": process.version, platform: process.platform, @@ -65,8 +137,8 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const envEnabled = typeof envPath === "string" && envPath.trim().length > 0; - if (input.json || envEnabled) { - if (input.interactive) { + if (input.json || envEnabled || input.quiet) { + if (interactiveMode) { await ensureOnline(); const regions = await fetchRegions(); @@ -102,7 +174,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { } await ensureOnline(); - const result = await createDatabase(region, userAgent, cliRunId); + const result = await createDatabase( + region, + userAgent, + cliRunId, + "cli", + ttlSeconds ?? undefined + ); await flushAnalytics(); if (input.json) { @@ -118,13 +196,30 @@ export async function handleCreate(input: CreateFlagsInput): Promise { process.exit(1); } - const writeResult = writeEnvFile(envPath!, result.connectionString, result.claimUrl); - if (!writeResult.success) { - printError(`Failed to write environment variables to ${envPath}: ${writeResult.error}`); - process.exit(1); + if (envEnabled) { + const writeResult = writeEnvFile(envPath!, result.connectionString, result.claimUrl); + if (!writeResult.success) { + printError(`Failed to write environment variables to ${envPath}: ${writeResult.error}`); + process.exit(1); + } + + if (!input.quiet) { + printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); + } + } + + if (input.copy) { + applyCopyFlag(result, input.quiet); + } + + if (input.open) { + applyOpenFlag(result, input.quiet); + } + + if (input.quiet) { + console.log(result.connectionString ?? ""); } - printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); return; } @@ -166,7 +261,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const spinner = createSpinner(); spinner.start(region); - const result = await createDatabase(region, userAgent, cliRunId); + const result = await createDatabase( + region, + userAgent, + cliRunId, + "cli", + ttlSeconds ?? undefined + ); if (!result.success) { spinner.error(result.message); @@ -176,6 +277,15 @@ export async function handleCreate(input: CreateFlagsInput): Promise { spinner.success(); printDatabaseResult(result); + + if (input.copy) { + applyCopyFlag(result, false); + } + + if (input.open) { + applyOpenFlag(result, false); + } + showOutro(); await flushAnalytics(); } diff --git a/create-db/src/cli/flags.ts b/create-db/src/cli/flags.ts index dc90549..3378bf7 100644 --- a/create-db/src/cli/flags.ts +++ b/create-db/src/cli/flags.ts @@ -25,11 +25,37 @@ export const CreateFlags = z.object({ .optional() .describe("Write DATABASE_URL and CLAIM_URL to the specified .env file") .meta({ alias: "e" }), + ttl: z + .preprocess( + (value) => (value === true ? "" : value), + z.string() + ) + .optional() + .describe("Auto-delete after (30m, 1h-24h)") + .meta({ alias: "t" }), + copy: z + .boolean() + .optional() + .default(false) + .describe("Copy the connection string to your clipboard") + .meta({ alias: "c" }), + quiet: z + .boolean() + .optional() + .default(false) + .describe("Only output the connection string") + .meta({ alias: "q" }), + open: z + .boolean() + .optional() + .default(false) + .describe("Open the claim URL in your browser") + .meta({ alias: "o" }), userAgent: z .string() .optional() .describe("Custom user agent string (e.g. 'test/test')") - .meta({ alias: "u" }), + .meta({ alias: "u", hidden: true }), }); /** Inferred type from CreateFlags schema. */ diff --git a/create-db/src/cli/output.ts b/create-db/src/cli/output.ts index a8494e1..6cd9313 100644 --- a/create-db/src/cli/output.ts +++ b/create-db/src/cli/output.ts @@ -1,10 +1,13 @@ import { intro, outro, cancel, log, spinner as clackSpinner } from "@clack/prompts"; +import { spawnSync } from "child_process"; import fs from "fs"; import pc from "picocolors"; import terminalLink from "terminal-link"; import type { DatabaseResult } from "../types.js"; +type OperationResult = { success: true } | { success: false; error: string }; + /** Display the CLI intro message. */ export function showIntro() { intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); @@ -126,3 +129,73 @@ export function writeEnvFile( }; } } + +/** + * Copy text to the system clipboard. + * @param text - Text to copy + */ +export function copyToClipboard(text: string): OperationResult { + const commands = + process.platform === "darwin" + ? [{ command: "pbcopy", args: [] as string[] }] + : process.platform === "win32" + ? [{ command: "cmd", args: ["/c", "clip"] }] + : [ + { command: "wl-copy", args: [] as string[] }, + { command: "xclip", args: ["-selection", "clipboard"] }, + { command: "xsel", args: ["--clipboard", "--input"] }, + ]; + + const errors: string[] = []; + + for (const { command, args } of commands) { + const result = spawnSync(command, args, { + input: text, + encoding: "utf8", + stdio: ["pipe", "ignore", "pipe"], + }); + + if (result.status === 0) { + return { success: true }; + } + + if (result.error) { + errors.push(`${command}: ${result.error.message}`); + } else if (result.stderr?.trim()) { + errors.push(`${command}: ${result.stderr.trim()}`); + } + } + + return { + success: false, + error: errors[0] || "No clipboard command is available on this system.", + }; +} + +/** + * Open a URL in the user's default browser. + * @param url - URL to open + */ +export function openUrlInBrowser(url: string): OperationResult { + const command = + process.platform === "darwin" + ? { command: "open", args: [url] } + : process.platform === "win32" + ? { command: "cmd", args: ["/c", "start", "", url] } + : { command: "xdg-open", args: [url] }; + + const result = spawnSync(command.command, command.args, { stdio: "ignore" }); + + if (result.status === 0) { + return { success: true }; + } + + if (result.error) { + return { success: false, error: result.error.message }; + } + + return { + success: false, + error: `Command failed: ${command.command}`, + }; +} diff --git a/create-db/src/core/database.ts b/create-db/src/core/database.ts index ab6263f..a3e4cd5 100644 --- a/create-db/src/core/database.ts +++ b/create-db/src/core/database.ts @@ -15,21 +15,28 @@ export async function createDatabaseCore( claimDbWorkerUrl: string, userAgent?: string, cliRunId?: string, - source?: "programmatic" | "cli" + source?: "programmatic" | "cli", + ttlSeconds?: number ): Promise { const name = new Date().toISOString(); const runId = cliRunId ?? randomUUID(); + const payload: Record = { + region, + name, + utm_source: getCommandName(), + userAgent, + source: source || "cli", + }; + + if (typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0) { + payload.ttlSeconds = Math.floor(ttlSeconds); + } + const resp = await fetch(`${createDbWorkerUrl}/create`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - region, - name, - utm_source: getCommandName(), - userAgent, - source: source || "cli", - }), + body: JSON.stringify(payload), }); if (resp.status === 429) { @@ -132,7 +139,12 @@ export async function createDatabaseCore( : null; const claimUrl = `${claimDbWorkerUrl}/claim?projectID=${projectId}&utm_source=${userAgent || getCommandName()}&utm_medium=cli`; - const expiryDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const ttlSecondsToUse = + typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0 + ? Math.floor(ttlSeconds) + : 24 * 60 * 60; + const expiryDate = new Date(Date.now() + ttlSecondsToUse * 1000); void sendAnalytics( "create_db:database_created", diff --git a/create-db/src/core/services.ts b/create-db/src/core/services.ts index 5889ec8..3d49988 100644 --- a/create-db/src/core/services.ts +++ b/create-db/src/core/services.ts @@ -61,13 +61,15 @@ export function validateRegionId(region: string) { * @param userAgent - Optional custom user agent string * @param cliRunId - Optional unique identifier for this CLI run * @param source - Whether called from CLI or programmatic API + * @param ttlSeconds - Optional database lifetime in seconds * @returns A promise resolving to the database creation result */ export function createDatabase( region: string, userAgent?: string, cliRunId?: string, - source?: "programmatic" | "cli" + source?: "programmatic" | "cli", + ttlSeconds?: number ) { return createDatabaseCore( region, @@ -75,6 +77,7 @@ export function createDatabase( CLAIM_DB_WORKER_URL, userAgent, cliRunId, - source + source, + ttlSeconds ); } diff --git a/create-db/src/utils/ttl.ts b/create-db/src/utils/ttl.ts new file mode 100644 index 0000000..b140c3e --- /dev/null +++ b/create-db/src/utils/ttl.ts @@ -0,0 +1,10 @@ +const TTL_TO_SECONDS: Record = { "30m": 30 * 60 }; + +for (let hour = 1; hour <= 24; hour += 1) { + TTL_TO_SECONDS[`${hour}h`] = hour * 60 * 60; +} + +export function parseTtlToSeconds(value: string): number | null { + const normalized = value.trim().toLowerCase(); + return TTL_TO_SECONDS[normalized] ?? null; +} From 17f33fc971c1424a8e5c85d53aeed72a8d33abd7 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Mon, 23 Feb 2026 22:09:34 -0500 Subject: [PATCH 2/8] updated package-lock --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0cdeb..6274eb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,8 +133,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 trpc-cli: - specifier: ^0.12.1 - version: 0.12.1(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13) + specifier: ^0.12.4 + version: 0.12.4(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13) zod: specifier: ^4.1.13 version: 4.1.13 @@ -5644,8 +5644,8 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - trpc-cli@0.12.1: - resolution: {integrity: sha512-/D/mIQf3tUrS7ZKJZ1gmSPJn2psAABJfkC5Eevm55SZ4s6KwANOUNlwhAGXN9HT4VSJVfoF2jettevE9vHPQlg==} + trpc-cli@0.12.4: + resolution: {integrity: sha512-Yo2Ob5J7hUZSWZ2A1M9Kb+0qfSxwmcmYIs3kkQyLd3sn0qU4ryGzNsySfrY3+urqp6FnDnIIdbCSqC9BKxK6Ag==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -12482,7 +12482,7 @@ snapshots: tree-kill@1.2.2: {} - trpc-cli@0.12.1(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13): + trpc-cli@0.12.4(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13): dependencies: commander: 14.0.2 optionalDependencies: From 870f41bf9db08473078957aa77230e28dd7615c0 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Mon, 23 Feb 2026 22:10:18 -0500 Subject: [PATCH 3/8] version bumps to `1.2.0` from `1.1.4` --- create-db/package.json | 2 +- create-pg/package.json | 2 +- create-postgres/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/create-db/package.json b/create-db/package.json index c0f0e69..26f715d 100644 --- a/create-db/package.json +++ b/create-db/package.json @@ -1,6 +1,6 @@ { "name": "create-db", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "type": "module", "exports": { diff --git a/create-pg/package.json b/create-pg/package.json index ba686d6..e676fba 100644 --- a/create-pg/package.json +++ b/create-pg/package.json @@ -1,6 +1,6 @@ { "name": "create-pg", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "author": "prisma", "repository": { diff --git a/create-postgres/package.json b/create-postgres/package.json index 123db43..8849371 100644 --- a/create-postgres/package.json +++ b/create-postgres/package.json @@ -1,6 +1,6 @@ { "name": "create-postgres", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "author": "prisma", "repository": { From e1d9279c3e2e31a108ea66e6c913b86cc04473b8 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Mon, 23 Feb 2026 22:28:05 -0500 Subject: [PATCH 4/8] chore(docs): apply coderabbit comments --- create-db-worker/src/delete-workflow.ts | 9 ++++++--- create-db/src/cli/commands/create.ts | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/create-db-worker/src/delete-workflow.ts b/create-db-worker/src/delete-workflow.ts index 4ddcc62..1eb3ea5 100644 --- a/create-db-worker/src/delete-workflow.ts +++ b/create-db-worker/src/delete-workflow.ts @@ -17,10 +17,13 @@ export class DeleteDbWorkflow extends WorkflowEntrypoint { throw new Error('No projectID provided.'); } + const MIN_TTL_SECONDS = 30 * 60; + const MAX_TTL_SECONDS = 24 * 60 * 60; + const effectiveTtlSeconds = - typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0 - ? Math.floor(ttlSeconds) - : 24 * 60 * 60; + typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) + ? Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, Math.floor(ttlSeconds))) + : MAX_TTL_SECONDS; await step.sleep(`wait ${effectiveTtlSeconds} seconds`, `${effectiveTtlSeconds} seconds`); diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts index 1076dc9..4995a43 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -208,6 +208,10 @@ export async function handleCreate(input: CreateFlagsInput): Promise { } } + if (input.quiet) { + console.log(result.connectionString ?? ""); + } + if (input.copy) { applyCopyFlag(result, input.quiet); } @@ -216,10 +220,6 @@ export async function handleCreate(input: CreateFlagsInput): Promise { applyOpenFlag(result, input.quiet); } - if (input.quiet) { - console.log(result.connectionString ?? ""); - } - return; } From ede97892c281e2876f6669b2325bd08e6df5b01e Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Tue, 24 Feb 2026 11:29:35 -0500 Subject: [PATCH 5/8] ttl fixed and added to programmatic import --- create-db-worker/src/delete-workflow.ts | 16 +++++----- create-db-worker/src/index.ts | 17 +++++------ create-db-worker/src/ttl.ts | 22 ++++++++++++++ create-db/README.md | 1 + create-db/__tests__/flags.test.ts | 2 +- create-db/__tests__/ttl.test.ts | 32 +++++++++++--------- create-db/src/cli/commands/create.ts | 38 +++++------------------- create-db/src/cli/flags.ts | 3 +- create-db/src/core/database.ts | 16 +++++----- create-db/src/core/services.ts | 6 ++-- create-db/src/index.ts | 17 ++++++++++- create-db/src/types.ts | 1 + create-db/src/utils/ttl.ts | 39 +++++++++++++++++++++---- 13 files changed, 127 insertions(+), 83 deletions(-) create mode 100644 create-db-worker/src/ttl.ts diff --git a/create-db-worker/src/delete-workflow.ts b/create-db-worker/src/delete-workflow.ts index 1eb3ea5..41db5a8 100644 --- a/create-db-worker/src/delete-workflow.ts +++ b/create-db-worker/src/delete-workflow.ts @@ -1,8 +1,9 @@ import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; +import { parseTtlMsInput, clampTtlMs } from './ttl'; type Params = { projectID: string; - ttlSeconds?: number; + ttlMs?: number; }; type Env = { @@ -11,19 +12,15 @@ type Env = { export class DeleteDbWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { - const { projectID, ttlSeconds } = event.payload; + const { projectID, ttlMs } = event.payload; if (!projectID) { throw new Error('No projectID provided.'); } - const MIN_TTL_SECONDS = 30 * 60; - const MAX_TTL_SECONDS = 24 * 60 * 60; - - const effectiveTtlSeconds = - typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) - ? Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, Math.floor(ttlSeconds))) - : MAX_TTL_SECONDS; + const rawTtlMs = parseTtlMsInput(ttlMs); + const effectiveTtlMs = clampTtlMs(rawTtlMs); + const effectiveTtlSeconds = Math.ceil(effectiveTtlMs / 1000); await step.sleep(`wait ${effectiveTtlSeconds} seconds`, `${effectiveTtlSeconds} seconds`); @@ -38,6 +35,7 @@ export class DeleteDbWorkflow extends WorkflowEntrypoint { if (!res.ok) { throw new Error(`Failed to delete project: ${res.statusText}`); } + } } diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index 3724dce..f509d04 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -1,6 +1,7 @@ import DeleteDbWorkflow from './delete-workflow'; import DeleteStaleProjectsWorkflow from './delete-stale-workflow'; import { PosthogEventCapture } from './analytics'; +import { parseTtlMsInput, isTtlMsInRange } from './ttl'; interface Env { INTEGRATION_TOKEN: string; DELETE_DB_WORKFLOW: Workflow; @@ -132,7 +133,7 @@ export default { analytics?: { eventName?: string; properties?: Record }; userAgent?: string; source?: 'programmatic' | 'cli'; - ttlSeconds?: number; + ttlMs?: number; }; let body: CreateDbBody = {}; @@ -143,15 +144,11 @@ export default { return new Response('Invalid JSON body', { status: 400 }); } - const { region, name, analytics: analyticsData, userAgent, source, ttlSeconds } = body; + const { region, name, analytics: analyticsData, userAgent, source, ttlMs } = body; + const parsedTtlMs = parseTtlMsInput(ttlMs); - const parsedTtlSeconds = - typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) - ? Math.floor(ttlSeconds) - : undefined; - - if (ttlSeconds !== undefined && (!parsedTtlSeconds || parsedTtlSeconds <= 0)) { - return new Response('Invalid ttlSeconds in request body', { status: 400 }); + if (parsedTtlMs !== undefined && !isTtlMsInRange(parsedTtlMs)) { + return new Response('Invalid ttlMs in request body', { status: 400 }); } // Apply stricter rate limiting for programmatic requests @@ -222,7 +219,7 @@ export default { const projectID = response.data ? response.data.id : response.id; const workflowPromise = env.DELETE_DB_WORKFLOW.create({ - params: { projectID, ttlSeconds: parsedTtlSeconds }, + params: { projectID, ttlMs: parsedTtlMs }, }); const analyticsPromise = env.CREATE_DB_DATASET.writeDataPoint({ diff --git a/create-db-worker/src/ttl.ts b/create-db-worker/src/ttl.ts new file mode 100644 index 0000000..ce43f5d --- /dev/null +++ b/create-db-worker/src/ttl.ts @@ -0,0 +1,22 @@ +export const MIN_TTL_MS = 30 * 60 * 1000; +export const MAX_TTL_MS = 24 * 60 * 60 * 1000; + +export function parseTtlMsInput(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + + return Math.floor(value); +} + +export function isTtlMsInRange(value: number): boolean { + return value >= MIN_TTL_MS && value <= MAX_TTL_MS; +} + +export function clampTtlMs(value: number | undefined): number { + if (typeof value !== 'number') { + return MAX_TTL_MS; + } + + return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, value)); +} diff --git a/create-db/README.md b/create-db/README.md index ef94d5f..7477835 100644 --- a/create-db/README.md +++ b/create-db/README.md @@ -175,6 +175,7 @@ if (result.success) { |--------|------|-------------| | `region` | `RegionId` | AWS region for the database (optional, defaults to `us-east-1`) | | `userAgent` | `string` | Custom user agent string for tracking (optional) | +| `ttl` | `string` | TTL string (`30m`, `1h` ... `24h`) | ### `regions()` diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts index 179e6dd..1a60fe6 100644 --- a/create-db/__tests__/flags.test.ts +++ b/create-db/__tests__/flags.test.ts @@ -126,7 +126,7 @@ describe("CreateFlags schema", () => { }); it("passes through ttl strings for command-level validation", () => { - const ttlInputs = ["25h", "7d", "45s", "one-hour", "24"]; + const ttlInputs = ["25h", "7d", "10s", "45s", "one-hour", "24"]; for (const ttl of ttlInputs) { const result = CreateFlags.safeParse({ ttl }); diff --git a/create-db/__tests__/ttl.test.ts b/create-db/__tests__/ttl.test.ts index 0d3a858..a6bb91b 100644 --- a/create-db/__tests__/ttl.test.ts +++ b/create-db/__tests__/ttl.test.ts @@ -1,25 +1,29 @@ import { describe, it, expect } from "vitest"; -import { parseTtlToSeconds } from "../src/utils/ttl.js"; +import { parseTtlToMilliseconds } from "../src/utils/ttl.js"; -describe("parseTtlToSeconds", () => { +describe("parseTtlToMilliseconds", () => { it("parses supported ttl values", () => { - expect(parseTtlToSeconds("30m")).toBe(1800); - expect(parseTtlToSeconds("1h")).toBe(3600); - expect(parseTtlToSeconds("6h")).toBe(21600); - expect(parseTtlToSeconds("24h")).toBe(86400); + expect(parseTtlToMilliseconds("30m")).toBe(1_800_000); + expect(parseTtlToMilliseconds("1h")).toBe(3_600_000); + expect(parseTtlToMilliseconds("6h")).toBe(21_600_000); + expect(parseTtlToMilliseconds("24h")).toBe(86_400_000); }); it("is case-insensitive", () => { - expect(parseTtlToSeconds("2H")).toBe(7200); - expect(parseTtlToSeconds("24H")).toBe(86400); + expect(parseTtlToMilliseconds("30M")).toBe(1_800_000); + expect(parseTtlToMilliseconds("2H")).toBe(7_200_000); + expect(parseTtlToMilliseconds("24H")).toBe(86_400_000); }); it("rejects values outside the allowed range", () => { - expect(parseTtlToSeconds("")).toBeNull(); - expect(parseTtlToSeconds("0h")).toBeNull(); - expect(parseTtlToSeconds("25h")).toBeNull(); - expect(parseTtlToSeconds("7d")).toBeNull(); - expect(parseTtlToSeconds("45s")).toBeNull(); - expect(parseTtlToSeconds("abc")).toBeNull(); + expect(parseTtlToMilliseconds("")).toBeNull(); + expect(parseTtlToMilliseconds("0h")).toBeNull(); + expect(parseTtlToMilliseconds("9s")).toBeNull(); + expect(parseTtlToMilliseconds("10s")).toBeNull(); + expect(parseTtlToMilliseconds("45s")).toBeNull(); + expect(parseTtlToMilliseconds("1d")).toBeNull(); + expect(parseTtlToMilliseconds("25h")).toBeNull(); + expect(parseTtlToMilliseconds("7d")).toBeNull(); + expect(parseTtlToMilliseconds("abc")).toBeNull(); }); }); diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts index 4995a43..8043254 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -6,7 +6,7 @@ import type { RegionId, DatabaseResult } from "../../types.js"; import { getCommandName } from "../../core/database.js"; import { readUserEnvFile } from "../../utils/env-utils.js"; import { detectUserLocation, getRegionClosestToLocation } from "../../utils/geolocation.js"; -import { parseTtlToSeconds } from "../../utils/ttl.js"; +import { parseTtlToMilliseconds, buildTtlCliError } from "../../utils/ttl.js"; import { sendAnalyticsEvent, flushAnalytics, @@ -29,14 +29,6 @@ import { openUrlInBrowser, } from "../output.js"; -const TTL_EXAMPLES = [ - "Examples:", - "npx create-db --ttl 24h", - "npx create-db --ttl 8h", - "npx create-db --ttl 1h", - "npx create-db --ttl 30m", -].join("\n"); - function applyCopyFlag(result: DatabaseResult, quiet: boolean) { if (!result.connectionString) { printError("Connection string is unavailable, cannot copy to clipboard."); @@ -71,27 +63,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const CLI_NAME = getCommandName(); if (input.ttl === "") { - printError( - [ - "Could not create database: --ttl was provided without a value.", - "Allowed values are 30m or 1h-24h.", - "", - TTL_EXAMPLES, - ].join("\n") - ); + printError(buildTtlCliError("Could not create database: --ttl was provided without a value.")); process.exit(1); } - const ttlSeconds = input.ttl ? parseTtlToSeconds(input.ttl) : null; - if (typeof input.ttl === "string" && ttlSeconds === null) { - printError( - [ - `Could not create database: --ttl value "${input.ttl}" is invalid.`, - "Allowed values are 30m or 1h-24h.", - "", - TTL_EXAMPLES, - ].join("\n") - ); + const ttlMs = input.ttl ? parseTtlToMilliseconds(input.ttl) : null; + if (typeof input.ttl === "string" && ttlMs === null) { + printError(buildTtlCliError(`Could not create database: --ttl value "${input.ttl}" is invalid.`)); process.exit(1); } @@ -117,7 +95,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { "has-copy-flag": input.copy, "has-quiet-flag": input.quiet, "has-open-flag": input.open, - "ttl-seconds": ttlSeconds ?? undefined, + "ttl-ms": ttlMs ?? undefined, "user-agent": userAgent || undefined, "node-version": process.version, platform: process.platform, @@ -179,7 +157,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { userAgent, cliRunId, "cli", - ttlSeconds ?? undefined + ttlMs ?? undefined ); await flushAnalytics(); @@ -266,7 +244,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { userAgent, cliRunId, "cli", - ttlSeconds ?? undefined + ttlMs ?? undefined ); if (!result.success) { diff --git a/create-db/src/cli/flags.ts b/create-db/src/cli/flags.ts index 3378bf7..00caca4 100644 --- a/create-db/src/cli/flags.ts +++ b/create-db/src/cli/flags.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { RegionSchema } from "../types.js"; +import { TTL_HELP_DESCRIPTION } from "../utils/ttl.js"; /** * Zod schema for CLI flags used by the `create` command. @@ -31,7 +32,7 @@ export const CreateFlags = z.object({ z.string() ) .optional() - .describe("Auto-delete after (30m, 1h-24h)") + .describe(TTL_HELP_DESCRIPTION) .meta({ alias: "t" }), copy: z .boolean() diff --git a/create-db/src/core/database.ts b/create-db/src/core/database.ts index a3e4cd5..f0f2117 100644 --- a/create-db/src/core/database.ts +++ b/create-db/src/core/database.ts @@ -16,7 +16,7 @@ export async function createDatabaseCore( userAgent?: string, cliRunId?: string, source?: "programmatic" | "cli", - ttlSeconds?: number + ttlMs?: number ): Promise { const name = new Date().toISOString(); const runId = cliRunId ?? randomUUID(); @@ -29,8 +29,8 @@ export async function createDatabaseCore( source: source || "cli", }; - if (typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0) { - payload.ttlSeconds = Math.floor(ttlSeconds); + if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0) { + payload.ttlMs = Math.floor(ttlMs); } const resp = await fetch(`${createDbWorkerUrl}/create`, { @@ -140,11 +140,11 @@ export async function createDatabaseCore( const claimUrl = `${claimDbWorkerUrl}/claim?projectID=${projectId}&utm_source=${userAgent || getCommandName()}&utm_medium=cli`; - const ttlSecondsToUse = - typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0 - ? Math.floor(ttlSeconds) - : 24 * 60 * 60; - const expiryDate = new Date(Date.now() + ttlSecondsToUse * 1000); + const ttlMsToUse = + typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0 + ? Math.floor(ttlMs) + : 24 * 60 * 60 * 1000; + const expiryDate = new Date(Date.now() + ttlMsToUse); void sendAnalytics( "create_db:database_created", diff --git a/create-db/src/core/services.ts b/create-db/src/core/services.ts index 3d49988..9298dd0 100644 --- a/create-db/src/core/services.ts +++ b/create-db/src/core/services.ts @@ -61,7 +61,7 @@ export function validateRegionId(region: string) { * @param userAgent - Optional custom user agent string * @param cliRunId - Optional unique identifier for this CLI run * @param source - Whether called from CLI or programmatic API - * @param ttlSeconds - Optional database lifetime in seconds + * @param ttlMs - Optional database lifetime in milliseconds * @returns A promise resolving to the database creation result */ export function createDatabase( @@ -69,7 +69,7 @@ export function createDatabase( userAgent?: string, cliRunId?: string, source?: "programmatic" | "cli", - ttlSeconds?: number + ttlMs?: number ) { return createDatabaseCore( region, @@ -78,6 +78,6 @@ export function createDatabase( userAgent, cliRunId, source, - ttlSeconds + ttlMs ); } diff --git a/create-db/src/index.ts b/create-db/src/index.ts index b854318..e28c2c3 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -13,6 +13,7 @@ import { import { getCommandName } from "./core/database.js"; import { handleCreate, handleRegions } from "./cli/commands/index.js"; import { createDatabase, fetchRegions } from "./core/services.js"; +import { parseTtlToMilliseconds, TTL_RANGE_TEXT } from "./utils/ttl.js"; export type { Region, @@ -74,11 +75,25 @@ export function createDbCli() { export async function create( options?: ProgrammaticCreateOptions ): Promise { + const ttlMs = + typeof options?.ttl === "string" + ? parseTtlToMilliseconds(options.ttl) + : undefined; + + if (typeof options?.ttl === "string" && ttlMs === null) { + return { + success: false, + error: "invalid_ttl", + message: `Invalid ttl "${options.ttl}". Allowed range is ${TTL_RANGE_TEXT}.`, + }; + } + return createDatabase( options?.region || "us-east-1", options?.userAgent, undefined, - "programmatic" + "programmatic", + ttlMs ); } diff --git a/create-db/src/types.ts b/create-db/src/types.ts index b8a6a08..33375c4 100644 --- a/create-db/src/types.ts +++ b/create-db/src/types.ts @@ -131,4 +131,5 @@ export type RegionsResponse = Region[] | RegionsApiResponse; export interface ProgrammaticCreateOptions { region?: RegionId; userAgent?: string; + ttl?: string; } diff --git a/create-db/src/utils/ttl.ts b/create-db/src/utils/ttl.ts index b140c3e..82f59ad 100644 --- a/create-db/src/utils/ttl.ts +++ b/create-db/src/utils/ttl.ts @@ -1,10 +1,37 @@ -const TTL_TO_SECONDS: Record = { "30m": 30 * 60 }; +const HOUR_TTL_PATTERN = /^([1-9]|1\d|2[0-4])h$/; +export const MIN_TTL_MS = 30 * 60 * 1000; +export const MAX_TTL_MS = 24 * 60 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; +export const TTL_HELP_DESCRIPTION = "Auto-delete after (30m, 1h-24h)"; +export const TTL_ALLOWED_VALUES_TEXT = "30m, or 1h-24h"; +export const TTL_RANGE_TEXT = "30m to 24h"; +export const TTL_EXAMPLES_TEXT = [ + "Examples:", + "npx create-db --ttl 24h", + "npx create-db --ttl 8h", + "npx create-db --ttl 1h", + "npx create-db --ttl 30m", +].join("\n"); -for (let hour = 1; hour <= 24; hour += 1) { - TTL_TO_SECONDS[`${hour}h`] = hour * 60 * 60; +export function parseTtlToMilliseconds(value: string): number | null { + const normalized = value.trim().toLowerCase(); + if (normalized === "30m") { + return MIN_TTL_MS; + } + + const match = HOUR_TTL_PATTERN.exec(normalized); + if (!match) { + return null; + } + + return Number(match[1]) * ONE_HOUR_MS; } -export function parseTtlToSeconds(value: string): number | null { - const normalized = value.trim().toLowerCase(); - return TTL_TO_SECONDS[normalized] ?? null; +export function buildTtlCliError(message: string): string { + return [ + message, + `Allowed values are ${TTL_ALLOWED_VALUES_TEXT}.`, + "", + TTL_EXAMPLES_TEXT, + ].join("\n"); } From 1fc5e841d531536b560a0f39e953be61b11e35b7 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Tue, 24 Feb 2026 11:43:27 -0500 Subject: [PATCH 6/8] chore(create-db): coderabbit comments applied --- create-db-worker/src/index.ts | 6 +++++- create-db-worker/src/ttl.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index f509d04..3ab1962 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -133,7 +133,7 @@ export default { analytics?: { eventName?: string; properties?: Record }; userAgent?: string; source?: 'programmatic' | 'cli'; - ttlMs?: number; + ttlMs?: unknown; }; let body: CreateDbBody = {}; @@ -147,6 +147,10 @@ export default { const { region, name, analytics: analyticsData, userAgent, source, ttlMs } = body; const parsedTtlMs = parseTtlMsInput(ttlMs); + if (ttlMs !== undefined && parsedTtlMs === undefined) { + return new Response('Invalid ttlMs in request body', { status: 400 }); + } + if (parsedTtlMs !== undefined && !isTtlMsInRange(parsedTtlMs)) { return new Response('Invalid ttlMs in request body', { status: 400 }); } diff --git a/create-db-worker/src/ttl.ts b/create-db-worker/src/ttl.ts index ce43f5d..388e5f3 100644 --- a/create-db-worker/src/ttl.ts +++ b/create-db-worker/src/ttl.ts @@ -14,7 +14,7 @@ export function isTtlMsInRange(value: number): boolean { } export function clampTtlMs(value: number | undefined): number { - if (typeof value !== 'number') { + if (typeof value !== 'number' || !Number.isFinite(value)) { return MAX_TTL_MS; } From 29fba377c56c2d598672f7e5f86db883a8580842 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Tue, 24 Feb 2026 11:51:54 -0500 Subject: [PATCH 7/8] chore(create-db): update readme table --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0a5fcdb..28827d9 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,17 @@ This monorepo contains tools and services that enable developers to quickly prov ### Available Flags -| Flag | Description | Example | -| ---------------- | ------------------------------------------------- | -------------------- | -| `--region` | Specify database region | `--region us-east-1` | -| `--list-regions` | List available regions | `--list-regions` | -| `--interactive` | Enable interactive region selection | `--interactive` | -| `--help` | Show help information | `--help` | -| `--json` | Output the info in a JSON format | `--json` | -| `--env`, `-e` | Print DATABASE_URL to stdout; claim URL to stderr | `--env` | +| Flag | Description | Example | +| --------------------- | ------------------------------------------------------------ | ---------------------- | +| `--region`, `-r` | Specify database region | `--region us-east-1` | +| `--interactive`, `-i` | Enable interactive region selection | `--interactive` | +| `--json`, `-j` | Output machine-readable JSON | `--json` | +| `--env`, `-e` | Write `DATABASE_URL` and `CLAIM_URL` to the specified file | `--env .env` | +| `--ttl`, `-t` | Set auto-delete TTL (`30m`, `1h` ... `24h`) | `--ttl 5h` | +| `--copy`, `-c` | Copy connection string to clipboard | `--copy` | +| `--quiet`, `-q` | Output only the connection string | `--quiet` | +| `--open`, `-o` | Open claim URL in browser | `--open` | +| `--help`, `-h` | Show help information | `--help` | ### Examples From f3143b87e3bb10f2a4723d1f5af70b77978ba603 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 26 Feb 2026 21:03:55 +0530 Subject: [PATCH 8/8] refactor(create-db): validate ttl via zod schema --- create-db/__tests__/cli.test.ts | 12 ++++++ create-db/__tests__/flags.test.ts | 56 ++++++++++++++-------------- create-db/src/cli/commands/create.ts | 21 +++-------- create-db/src/cli/flags.ts | 48 +++++++++++++++++++----- create-db/src/index.ts | 2 +- 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/create-db/__tests__/cli.test.ts b/create-db/__tests__/cli.test.ts index b6c417d..2fe5578 100644 --- a/create-db/__tests__/cli.test.ts +++ b/create-db/__tests__/cli.test.ts @@ -79,6 +79,18 @@ describe("CLI error handling", () => { expect(result.exitCode).not.toBe(0); }, 10000); + it("fails validation when --ttl is provided without a value", async () => { + const result = await runCli(["--ttl"]); + expect(result.exitCode).not.toBe(0); + expect(result.all).toContain("Input validation failed"); + }); + + it("fails validation when --ttl value is invalid", async () => { + const result = await runCli(["--ttl", "25h"]); + expect(result.exitCode).not.toBe(0); + expect(result.all).toContain("Input validation failed"); + }); + it("shows error for unknown command", async () => { const result = await runCli(["unknown-command"]); expect(result.exitCode).not.toBe(0); diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts index 1a60fe6..4923210 100644 --- a/create-db/__tests__/flags.test.ts +++ b/create-db/__tests__/flags.test.ts @@ -114,35 +114,35 @@ describe("CreateFlags schema", () => { describe("ttl field", () => { it("accepts valid ttl strings", () => { - const validTtls = ["30m", "1h", "6h", "12h", "24h"]; + const validTtls = [ + { input: "30m", expectedMs: 1_800_000 }, + { input: "1h", expectedMs: 3_600_000 }, + { input: "6h", expectedMs: 21_600_000 }, + { input: "12h", expectedMs: 43_200_000 }, + { input: "24h", expectedMs: 86_400_000 }, + ]; - for (const ttl of validTtls) { - const result = CreateFlags.safeParse({ ttl }); + for (const { input, expectedMs } of validTtls) { + const result = CreateFlags.safeParse({ ttl: input }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.ttl).toBe(ttl); + expect(result.data.ttl).toBe(expectedMs); } } }); - it("passes through ttl strings for command-level validation", () => { + it("rejects invalid ttl strings", () => { const ttlInputs = ["25h", "7d", "10s", "45s", "one-hour", "24"]; for (const ttl of ttlInputs) { const result = CreateFlags.safeParse({ ttl }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ttl).toBe(ttl); - } + expect(result.success).toBe(false); } }); - it("coerces boolean ttl to empty string when value is missing", () => { + it("rejects missing ttl values", () => { const result = CreateFlags.safeParse({ ttl: true }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ttl).toBe(""); - } + expect(result.success).toBe(false); }); it("allows undefined", () => { @@ -248,7 +248,7 @@ describe("CreateFlags schema", () => { interactive: true, json: false, env: ".env.local", - ttl: "12h", + ttl: 43_200_000, copy: true, quiet: false, open: true, @@ -276,7 +276,7 @@ describe("CreateFlags schema", () => { describe("type inference", () => { it("CreateFlagsInput type matches schema output", () => { - const input: CreateFlagsInput = { + const result = CreateFlags.parse({ region: "us-east-1", interactive: false, json: true, @@ -286,18 +286,18 @@ describe("CreateFlags schema", () => { quiet: false, open: true, userAgent: "test/1.0", - }; - - const result = CreateFlags.parse(input); - expect(result.region).toBe(input.region); - expect(result.interactive).toBe(input.interactive); - expect(result.json).toBe(input.json); - expect(result.env).toBe(input.env); - expect(result.ttl).toBe(input.ttl); - expect(result.copy).toBe(input.copy); - expect(result.quiet).toBe(input.quiet); - expect(result.open).toBe(input.open); - expect(result.userAgent).toBe(input.userAgent); + }); + + const typedResult: CreateFlagsInput = result; + expect(typedResult.region).toBe("us-east-1"); + expect(typedResult.interactive).toBe(false); + expect(typedResult.json).toBe(true); + expect(typedResult.env).toBe(".env"); + expect(typedResult.ttl).toBe(86_400_000); + expect(typedResult.copy).toBe(true); + expect(typedResult.quiet).toBe(false); + expect(typedResult.open).toBe(true); + expect(typedResult.userAgent).toBe("test/1.0"); }); }); }); diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts index 8043254..e157fa8 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -6,7 +6,6 @@ import type { RegionId, DatabaseResult } from "../../types.js"; import { getCommandName } from "../../core/database.js"; import { readUserEnvFile } from "../../utils/env-utils.js"; import { detectUserLocation, getRegionClosestToLocation } from "../../utils/geolocation.js"; -import { parseTtlToMilliseconds, buildTtlCliError } from "../../utils/ttl.js"; import { sendAnalyticsEvent, flushAnalytics, @@ -61,17 +60,7 @@ function applyOpenFlag(result: DatabaseResult, quiet: boolean) { export async function handleCreate(input: CreateFlagsInput): Promise { const cliRunId = randomUUID(); const CLI_NAME = getCommandName(); - - if (input.ttl === "") { - printError(buildTtlCliError("Could not create database: --ttl was provided without a value.")); - process.exit(1); - } - - const ttlMs = input.ttl ? parseTtlToMilliseconds(input.ttl) : null; - if (typeof input.ttl === "string" && ttlMs === null) { - printError(buildTtlCliError(`Could not create database: --ttl value "${input.ttl}" is invalid.`)); - process.exit(1); - } + const ttlMs = input.ttl; const interactiveMode = input.interactive && !input.quiet; @@ -91,11 +80,11 @@ export async function handleCreate(input: CreateFlagsInput): Promise { "has-interactive-flag": input.interactive, "has-json-flag": input.json, "has-env-flag": !!input.env, - "has-ttl-flag": !!input.ttl, + "has-ttl-flag": input.ttl !== undefined, "has-copy-flag": input.copy, "has-quiet-flag": input.quiet, "has-open-flag": input.open, - "ttl-ms": ttlMs ?? undefined, + "ttl-ms": ttlMs, "user-agent": userAgent || undefined, "node-version": process.version, platform: process.platform, @@ -157,7 +146,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { userAgent, cliRunId, "cli", - ttlMs ?? undefined + ttlMs ); await flushAnalytics(); @@ -244,7 +233,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { userAgent, cliRunId, "cli", - ttlMs ?? undefined + ttlMs ); if (!result.success) { diff --git a/create-db/src/cli/flags.ts b/create-db/src/cli/flags.ts index 00caca4..f47d60e 100644 --- a/create-db/src/cli/flags.ts +++ b/create-db/src/cli/flags.ts @@ -1,6 +1,43 @@ import { z } from "zod"; import { RegionSchema } from "../types.js"; -import { TTL_HELP_DESCRIPTION } from "../utils/ttl.js"; +import { + TTL_HELP_DESCRIPTION, + buildTtlCliError, + parseTtlToMilliseconds, +} from "../utils/ttl.js"; + +const TTL_MISSING_VALUE_SENTINEL = "__create_db_ttl_missing_value__"; + +const TtlFlag = z + .preprocess( + (value) => (value === true ? TTL_MISSING_VALUE_SENTINEL : value), + z + .string() + .superRefine((value, ctx) => { + if (value === TTL_MISSING_VALUE_SENTINEL) { + ctx.addIssue({ + code: "custom", + message: buildTtlCliError( + "Could not create database: --ttl was provided without a value." + ), + }); + return; + } + + if (parseTtlToMilliseconds(value) === null) { + ctx.addIssue({ + code: "custom", + message: buildTtlCliError( + `Could not create database: --ttl value "${value}" is invalid.` + ), + }); + } + }) + .transform((value) => parseTtlToMilliseconds(value)!) + ) + .optional() + .describe(TTL_HELP_DESCRIPTION) + .meta({ alias: "t" }); /** * Zod schema for CLI flags used by the `create` command. @@ -26,14 +63,7 @@ export const CreateFlags = z.object({ .optional() .describe("Write DATABASE_URL and CLAIM_URL to the specified .env file") .meta({ alias: "e" }), - ttl: z - .preprocess( - (value) => (value === true ? "" : value), - z.string() - ) - .optional() - .describe(TTL_HELP_DESCRIPTION) - .meta({ alias: "t" }), + ttl: TtlFlag, copy: z .boolean() .optional() diff --git a/create-db/src/index.ts b/create-db/src/index.ts index e28c2c3..e4d788e 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -93,7 +93,7 @@ export async function create( options?.userAgent, undefined, "programmatic", - ttlMs + ttlMs ?? undefined ); }