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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions create-db-worker/src/delete-workflow.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
import { parseTtlMsInput, clampTtlMs } from './ttl';

type Params = {
projectID: string;
ttlMs?: number;
};

type Env = {
Expand All @@ -10,13 +12,17 @@ type Env = {

export class DeleteDbWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep): Promise<void> {
const { projectID } = event.payload;
const { projectID, ttlMs } = event.payload;

if (!projectID) {
throw new Error('No projectID provided.');
}

await step.sleep('wait 24 hours', '24 hours');
const rawTtlMs = parseTtlMsInput(ttlMs);
const effectiveTtlMs = clampTtlMs(rawTtlMs);
const effectiveTtlSeconds = Math.ceil(effectiveTtlMs / 1000);

await step.sleep(`wait ${effectiveTtlSeconds} seconds`, `${effectiveTtlSeconds} seconds`);

const res = await fetch(`https://api.prisma.io/v1/projects/${projectID}`, {
method: 'DELETE',
Expand All @@ -29,6 +35,7 @@ export class DeleteDbWorkflow extends WorkflowEntrypoint<Env, Params> {
if (!res.ok) {
throw new Error(`Failed to delete project: ${res.statusText}`);
}

}
}

Expand Down
17 changes: 15 additions & 2 deletions create-db-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -132,6 +133,7 @@ export default {
analytics?: { eventName?: string; properties?: Record<string, unknown> };
userAgent?: string;
source?: 'programmatic' | 'cli';
ttlMs?: unknown;
};

let body: CreateDbBody = {};
Expand All @@ -142,7 +144,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, 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 });
}

// Apply stricter rate limiting for programmatic requests
if (source === 'programmatic') {
Expand Down Expand Up @@ -211,7 +222,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, ttlMs: parsedTtlMs },
});

const analyticsPromise = env.CREATE_DB_DATASET.writeDataPoint({
blobs: ['database_created'],
Expand Down
22 changes: 22 additions & 0 deletions create-db-worker/src/ttl.ts
Original file line number Diff line number Diff line change
@@ -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' || !Number.isFinite(value)) {
return MAX_TTL_MS;
}

return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, value));
}
22 changes: 22 additions & 0 deletions create-db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` | `-e` | Write DATABASE_URL and CLAIM_URL to specified .env file |
| `--ttl <duration>` | `-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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -154,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()`

Expand Down
16 changes: 16 additions & 0 deletions create-db/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -75,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);
Expand Down
132 changes: 124 additions & 8 deletions create-db/__tests__/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,102 @@ describe("CreateFlags schema", () => {
});
});

describe("ttl field", () => {
it("accepts valid ttl strings", () => {
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 { input, expectedMs } of validTtls) {
const result = CreateFlags.safeParse({ ttl: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.ttl).toBe(expectedMs);
}
}
});

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(false);
}
});

it("rejects missing ttl values", () => {
const result = CreateFlags.safeParse({ ttl: true });
expect(result.success).toBe(false);
});

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" });
Expand All @@ -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",
};

Expand All @@ -148,6 +248,10 @@ describe("CreateFlags schema", () => {
interactive: true,
json: false,
env: ".env.local",
ttl: 43_200_000,
copy: true,
quiet: false,
open: true,
userAgent: "test/2.0.0",
});
}
Expand All @@ -161,27 +265,39 @@ 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();
}
});
});

describe("type inference", () => {
it("CreateFlagsInput type matches schema output", () => {
const input: CreateFlagsInput = {
const result = CreateFlags.parse({
region: "us-east-1",
interactive: false,
json: true,
env: ".env",
ttl: "24h",
copy: true,
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.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");
});
});
});
Expand Down
Loading